diff --git a/product_ux/README.rst b/product_ux/README.rst
index 4d55f53ac..5d4779ccb 100644
--- a/product_ux/README.rst
+++ b/product_ux/README.rst
@@ -18,11 +18,13 @@ Several Improvements to products:
#. Now the field "Active" (Archive) keeps tracking in chatter.
#. Incorporates the possibility to search a product by the vendor product code in the product.template and product.product models.
-#. Add smart button from pricelist to pricelists items.
+#. Add smart button from pricelist to pricelists items, with the number of items, and search and group the items by what they apply on and by product category.
+#. Surcharge pricelist rules are rounded by default to the "Product Price" precision (discount rules are not, so sales keep showing the discount).
#. Add product warranty field that was deprecated by odoo.
-#. Add new field "Description" on product UOMs.
+#. Add new field "Description" on product UOMs, shown in their list and form and used when searching them.
#. Incorporates the possibility to search the Pricelist Price of products in the tree view.
-#. Renders the Dymo product label report in batches so large print jobs (thousands of labels) do not crash wkhtmltopdf by memory.
+#. Renders the product labels (Dymo and sheets) in batches of pages so large print jobs (thousands of labels) do not crash wkhtmltopdf by memory.
+#. Lets a consultant choose up to 3 extra product fields in Settings, so the autocomplete of the product field also finds by them. Several words, in any order and spread across those fields, are resolved in a single query. Without configuration the search behaves exactly like Odoo. Fields that Odoo cannot search (HTML, attachments, neither stored nor searchable, or that the user configuring them cannot read, on any step of the path) are rejected when saving, and a path that reaches a model the user cannot read is skipped for that user instead of breaking the autocomplete. Be careful on a very large catalogue: the extra query reads it whole, so measure before turning this on.
Installation
============
@@ -36,7 +38,7 @@ Configuration
To configure this module, you need to:
-#. Optionally set the system parameter ``product_ux.dymo_label_batch_size`` (default 200) to tune how many Dymo labels are rendered per wkhtmltopdf call.
+#. Optionally set the system parameter ``product_ux.dymo_label_batch_size`` (default 200) to tune how many labels are rendered per wkhtmltopdf call (on sheets it is rounded down to whole pages, at least one). Set it to 0 to render the labels in a single call.
Usage
=====
diff --git a/product_ux/__manifest__.py b/product_ux/__manifest__.py
index 2342b3c81..2b27642d3 100644
--- a/product_ux/__manifest__.py
+++ b/product_ux/__manifest__.py
@@ -19,7 +19,7 @@
##############################################################################
{
"name": "Product UX",
- "version": "19.0.1.2.0",
+ "version": "20.0.1.0.0",
"category": "Products",
"sequence": 14,
"summary": "",
@@ -31,6 +31,7 @@
"product",
],
"data": [
+ "views/res_config_settings_views.xml",
"views/product_template_views.xml",
"views/product_product_views.xml",
"views/product_pricelist_item_view.xml",
@@ -40,7 +41,7 @@
"demo": [
"demo/product_pricelist_demo.xml",
],
- "installable": False,
+ "installable": True,
"auto_install": False,
"application": False,
}
diff --git a/product_ux/demo/product_pricelist_demo.xml b/product_ux/demo/product_pricelist_demo.xml
index 793161db9..a493f3c6b 100644
--- a/product_ux/demo/product_pricelist_demo.xml
+++ b/product_ux/demo/product_pricelist_demo.xml
@@ -12,6 +12,6 @@
153
- formula
+ discount
diff --git a/product_ux/i18n/es.po b/product_ux/i18n/es.po
index bb27ae8ba..ffe6d6f4f 100644
--- a/product_ux/i18n/es.po
+++ b/product_ux/i18n/es.po
@@ -2,27 +2,148 @@
# This file contains the translation of the following modules:
# * product_ux
#
-# Translators:
-# ADHOC - Bot , 2025
-#
msgid ""
msgstr ""
-"Project-Id-Version: Odoo Server 19.0+e\n"
+"Project-Id-Version: Odoo Server 20.0+e\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-09-15 18:15+0000\n"
-"PO-Revision-Date: 2025-11-03 17:41+0000\n"
+"POT-Creation-Date: 2026-10-09 16:41+0000\n"
+"PO-Revision-Date: 2026-10-09 16:42+0000\n"
"Last-Translator: ADHOC - Bot , 2025\n"
"Language-Team: Spanish (https://app.transifex.com/adhoc/teams/46451/es/)\n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
-"Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? "
-"1 : 2;\n"
+"Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
#. module: product_ux
-#: model_terms:ir.ui.view,arch_db:product_ux.product_pricelist_view
-msgid "Elementos de tarifa"
+#. odoo-python
+#: code:addons/product_ux/models/res_config_settings.py:0
+msgid ""
+"\"%(name)s\" is a %(type)s field. Only text fields and relations can be "
+"searched."
+msgstr ""
+"\"%(name)s\" es un campo %(type)s. Solo se pueden buscar campos de texto y "
+"relaciones."
+
+#. module: product_ux
+#. odoo-python
+#: code:addons/product_ux/models/res_config_settings.py:0
+msgid ""
+"\"%s\" cannot be searched: it is neither stored nor has a search method."
+msgstr ""
+"\"%s\" no se puede buscar: no está almacenado ni tiene un método de búsqueda."
+
+#. module: product_ux
+#. odoo-python
+#: code:addons/product_ux/models/res_config_settings.py:0
+msgid ""
+"\"%s\" is an HTML field. Searching inside HTML is the main cause of slow "
+"searches: use a text field."
+msgstr ""
+"\"%s\" es un campo HTML. Buscar dentro de HTML es la principal causa de las "
+"búsquedas lentas: use un campo de texto."
+
+#. module: product_ux
+#. odoo-python
+#: code:addons/product_ux/models/res_config_settings.py:0
+msgid "\"%s\" is an attachment, it cannot be searched."
+msgstr "\"%s\" es un adjunto, no se puede buscar."
+
+#. module: product_ux
+#. odoo-python
+#: code:addons/product_ux/models/res_config_settings.py:0
+msgid "\"%s\" is not a relational field, the path cannot be followed."
+msgstr "\"%s\" no es un campo relacional, no se puede seguir el camino."
+
+#. module: product_ux
+#. odoo-python
+#: code:addons/product_ux/models/res_config_settings.py:0
+msgid "\"%s\" is restricted by groups you do not have."
+msgstr "\"%s\" está restringido por grupos que usted no tiene."
+
+#. module: product_ux
+#: model:ir.module.module,description:base.module_product_ux
+msgid ""
+".. |company| replace:: ADHOC SA\n"
+"\n"
+".. |company_logo| image:: https://raw.githubusercontent.com/ingadhoc/maintainer-tools/master/resources/adhoc-logo.png\n"
+" :alt: ADHOC SA\n"
+" :target: https://www.adhoc.com.ar\n"
+"\n"
+".. |icon| image:: https://raw.githubusercontent.com/ingadhoc/maintainer-tools/master/resources/adhoc-icon.png\n"
+"\n"
+".. image:: https://img.shields.io/badge/license-AGPL--3-blue.png\n"
+" :target: https://www.gnu.org/licenses/agpl\n"
+" :alt: License: AGPL-3\n"
+"\n"
+"=================\n"
+"Product Usability\n"
+"=================\n"
+"\n"
+"Several Improvements to products:\n"
+"\n"
+"#. Now the field \"Active\" (Archive) keeps tracking in chatter.\n"
+"#. Incorporates the possibility to search a product by the vendor product code in the product.template and product.product models.\n"
+"#. Add smart button from pricelist to pricelists items, with the number of items, and search and group the items by what they apply on and by product category.\n"
+"#. Surcharge pricelist rules are rounded by default to the \"Product Price\" precision (discount rules are not, so sales keep showing the discount).\n"
+"#. Add product warranty field that was deprecated by odoo.\n"
+"#. Add new field \"Description\" on product UOMs, shown in their list and form and used when searching them.\n"
+"#. Incorporates the possibility to search the Pricelist Price of products in the tree view.\n"
+"#. Renders the product labels (Dymo and sheets) in batches of pages so large print jobs (thousands of labels) do not crash wkhtmltopdf by memory.\n"
+"#. Lets a consultant choose up to 3 extra product fields in Settings, so the autocomplete of the product field also finds by them. Several words, in any order and spread across those fields, are resolved in a single query. Without configuration the search behaves exactly like Odoo. Fields that Odoo cannot search (HTML, attachments, neither stored nor searchable, or that the user configuring them cannot read, on any step of the path) are rejected when saving, and a path that reaches a model the user cannot read is skipped for that user instead of breaking the autocomplete. Be careful on a very large catalogue: the extra query reads it whole, so measure before turning this on.\n"
+"\n"
+"Installation\n"
+"============\n"
+"\n"
+"To install this module, you need to:\n"
+"\n"
+"#. Just install this module.\n"
+"\n"
+"Configuration\n"
+"=============\n"
+"\n"
+"To configure this module, you need to:\n"
+"\n"
+"#. Optionally set the system parameter ``product_ux.dymo_label_batch_size`` (default 200) to tune how many labels are rendered per wkhtmltopdf call (on sheets it is rounded down to whole pages, at least one). Set it to 0 to render the labels in a single call.\n"
+"\n"
+"Usage\n"
+"=====\n"
+"\n"
+"To use this module, you need to:\n"
+"\n"
+"\n"
+".. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas\n"
+" :alt: Try me on Runbot\n"
+" :target: http://runbot.adhoc.com.ar/\n"
+"\n"
+"Bug Tracker\n"
+"===========\n"
+"\n"
+"Bugs are tracked on `GitHub Issues\n"
+"`_. In case of trouble, please\n"
+"check there if your issue has already been reported. If you spotted it first,\n"
+"help us smashing it by providing a detailed and welcomed feedback.\n"
+"\n"
+"Credits\n"
+"=======\n"
+"\n"
+"Images\n"
+"------\n"
+"\n"
+"* |company| |icon|\n"
+"\n"
+"Contributors\n"
+"------------\n"
+"\n"
+"Maintainer\n"
+"----------\n"
+"\n"
+"|company_logo|\n"
+"\n"
+"This module is maintained by the |company|.\n"
+"\n"
+"To contribute to this module, please visit https://www.adhoc.com.ar.\n"
msgstr ""
#. module: product_ux
@@ -32,15 +153,20 @@ msgid "Active"
msgstr "Activo"
#. module: product_ux
-#: model:ir.model.fields,field_description:product_ux.field_product_pricelist_item__applied_description
-msgid "Applied Description"
-msgstr "Descripción aplicada"
+#: model_terms:ir.ui.view,arch_db:product_ux.res_config_settings_view_form
+msgid "Advanced Search Fields"
+msgstr "Campos de búsqueda avanzada"
#. module: product_ux
#: model_terms:ir.ui.view,arch_db:product_ux.product_pricelist_item_search_view
msgid "Apply On"
msgstr "Aplicar en"
+#. module: product_ux
+#: model:ir.model,name:product_ux.model_res_config_settings
+msgid "Config Settings"
+msgstr "Ajustes de configuración"
+
#. module: product_ux
#: model:ir.model.fields,field_description:product_ux.field_uom_uom__description
msgid "Description"
@@ -48,28 +174,50 @@ msgstr "Descripción"
#. module: product_ux
#: model:ir.model.fields,field_description:product_ux.field_ir_actions_report__display_name
+#: model:ir.model.fields,field_description:product_ux.field_product_pricelist__display_name
#: model:ir.model.fields,field_description:product_ux.field_product_pricelist_item__display_name
#: model:ir.model.fields,field_description:product_ux.field_product_product__display_name
+#: model:ir.model.fields,field_description:product_ux.field_product_search_mixin__display_name
#: model:ir.model.fields,field_description:product_ux.field_product_template__display_name
+#: model:ir.model.fields,field_description:product_ux.field_res_config_settings__display_name
#: model:ir.model.fields,field_description:product_ux.field_uom_uom__display_name
msgid "Display Name"
-msgstr "Nombre para mostrar"
+msgstr "Nombre a mostrar"
+
+#. module: product_ux
+#: model:ir.model,name:product_ux.model_product_search_mixin
+msgid "Extended Product Search"
+msgstr "Búsqueda extendida de productos"
#. module: product_ux
#. odoo-python
-#: code:addons/product_ux/migrations/18.0.1.4.0/post-migration.py:0
-msgid ""
-"Hicimos una actualización automática para mejorar la precisión de las listas "
-"de precios. En las reglas de listas de precio tipo \"fórmula\" que no tenían "
-"una precisión de redondeo definida, aplicamos la precisión de precios "
-"configurada en su base de datos. Se ajustaron %(count)s reglas de precios."
-msgstr ""
+#: code:addons/product_ux/models/res_config_settings.py:0
+msgid "Field \"%(path)s\" does not exist on %(model)s."
+msgstr "El campo \"%(path)s\" no existe en %(model)s."
+
+#. module: product_ux
+#: model:ir.model.fields,field_description:product_ux.field_res_config_settings__product_search_field_1
+msgid "Field 1"
+msgstr "Campo 1"
+
+#. module: product_ux
+#: model:ir.model.fields,field_description:product_ux.field_res_config_settings__product_search_field_2
+msgid "Field 2"
+msgstr "Campo 2"
+
+#. module: product_ux
+#: model:ir.model.fields,field_description:product_ux.field_res_config_settings__product_search_field_3
+msgid "Field 3"
+msgstr "Campo 3"
#. module: product_ux
#: model:ir.model.fields,field_description:product_ux.field_ir_actions_report__id
+#: model:ir.model.fields,field_description:product_ux.field_product_pricelist__id
#: model:ir.model.fields,field_description:product_ux.field_product_pricelist_item__id
#: model:ir.model.fields,field_description:product_ux.field_product_product__id
+#: model:ir.model.fields,field_description:product_ux.field_product_search_mixin__id
#: model:ir.model.fields,field_description:product_ux.field_product_template__id
+#: model:ir.model.fields,field_description:product_ux.field_res_config_settings__id
#: model:ir.model.fields,field_description:product_ux.field_uom_uom__id
msgid "ID"
msgstr ""
@@ -92,15 +240,21 @@ msgstr ""
"relación con otros modelos."
#. module: product_ux
+#: model:ir.model.fields,field_description:product_ux.field_product_pricelist__item_count
+msgid "Item Count"
+msgstr "Cantidad de elementos"
+
+#. module: product_ux
+#: model:ir.model,name:product_ux.model_product_pricelist
#: model:ir.model.fields,field_description:product_ux.field_product_product__pricelist_id
#: model:ir.model.fields,field_description:product_ux.field_product_template__pricelist_id
msgid "Pricelist"
-msgstr "Tarifa"
+msgstr "Lista de precios"
#. module: product_ux
-#: model_terms:ir.ui.view,arch_db:product_ux.product_pricelist_item_search_view
+#: model_terms:ir.ui.view,arch_db:product_ux.product_pricelist_view
msgid "Pricelist Items"
-msgstr "Items de Tarifa"
+msgstr "Elementos de tarifa"
#. module: product_ux
#: model:ir.model.fields,field_description:product_ux.field_product_product__pricelist_price
@@ -121,7 +275,6 @@ msgstr "Items de Tarifa"
#. module: product_ux
#: model:ir.model,name:product_ux.model_product_template
-#: model_terms:ir.ui.view,arch_db:product_ux.product_pricelist_item_search_view
msgid "Product"
msgstr "Producto"
@@ -131,9 +284,24 @@ msgid "Product Category"
msgstr "Categoría de producto"
#. module: product_ux
-#: model_terms:ir.ui.view,arch_db:product_ux.product_pricelist_item_search_view
-msgid "Product Template"
-msgstr "Plantilla de producto"
+#: model_terms:ir.ui.view,arch_db:product_ux.res_config_settings_view_form
+msgid "Product Search"
+msgstr "Búsqueda de productos"
+
+#. module: product_ux
+#: model:ir.model.fields,field_description:product_ux.field_res_config_settings__product_search_fields
+msgid "Product Search Fields"
+msgstr "Campos de búsqueda de productos"
+
+#. module: product_ux
+#: model:ir.model.fields,field_description:product_ux.field_res_config_settings__product_search_model
+msgid "Product Search Model"
+msgstr "Modelo de búsqueda de productos"
+
+#. module: product_ux
+#: model:ir.module.module,shortdesc:base.module_product_ux
+msgid "Product UX"
+msgstr "UX de productos"
#. module: product_ux
#: model:ir.model,name:product_ux.model_uom_uom
@@ -143,17 +311,27 @@ msgstr "Unidad de medida del producto"
#. module: product_ux
#: model:ir.model,name:product_ux.model_product_product
msgid "Product Variant"
-msgstr "Variante de producto"
+msgstr "Variante del producto"
#. module: product_ux
#: model:product.pricelist,name:product_ux.list0
msgid "Public Pricelist"
-msgstr ""
+msgstr "Tarifa pública"
#. module: product_ux
#: model:ir.model,name:product_ux.model_ir_actions_report
msgid "Report Action"
-msgstr ""
+msgstr "Acción de reporte"
+
+#. module: product_ux
+#: model:ir.model.fields,field_description:product_ux.field_res_config_settings__product_extend_search_fields
+msgid "Search products by more fields"
+msgstr "Buscar productos por más campos"
+
+#. module: product_ux
+#: model_terms:ir.ui.view,arch_db:product_ux.res_config_settings_view_form
+msgid "Select a field"
+msgstr "Seleccione un campo"
#. module: product_ux
#: model:ir.model.fields,help:product_ux.field_product_product__sellers_product_code
@@ -162,8 +340,8 @@ msgid ""
"This vendor's product code will be used when printing a request for "
"quotation. Keep empty to use the internal one."
msgstr ""
-"El código de producto de vendedor se usará al imprimir una solicitud de "
-"presupuesto. Déjelo vacío para usar el código interno."
+"Este código de producto del proveedor se usará al imprimir una solicitud de "
+"cotización. Déjelo vacío para usar el interno."
#. module: product_ux
#: model:ir.model.fields,field_description:product_ux.field_product_product__sellers_product_code
@@ -177,6 +355,16 @@ msgstr "Código de producto del proveedor"
msgid "Warranty"
msgstr "Garantía"
+#. module: product_ux
+#: model_terms:ir.ui.view,arch_db:product_ux.res_config_settings_view_form
+msgid ""
+"When picking a product in a sales or purchase line, also find it by the "
+"fields you choose. It does not change the Search... box of the product lists."
+msgstr ""
+"Al elegir un producto en una línea de venta o de compra, también lo "
+"encuentra por los campos que elija. No cambia el cuadro Buscar... de las "
+"listas de productos."
+
#. module: product_ux
#: model_terms:ir.ui.view,arch_db:product_ux.product_template_only_form_view
msgid "months"
diff --git a/product_ux/i18n/product_ux.pot b/product_ux/i18n/product_ux.pot
index 678fc076e..69da20b3d 100644
--- a/product_ux/i18n/product_ux.pot
+++ b/product_ux/i18n/product_ux.pot
@@ -4,10 +4,10 @@
#
msgid ""
msgstr ""
-"Project-Id-Version: Odoo Server 19.0+e\n"
+"Project-Id-Version: Odoo Server 20.0+e\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-09-15 18:15+0000\n"
-"PO-Revision-Date: 2026-09-15 18:15+0000\n"
+"POT-Creation-Date: 2026-10-09 16:41+0000\n"
+"PO-Revision-Date: 2026-10-09 16:41+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
@@ -16,8 +16,127 @@ msgstr ""
"Plural-Forms: \n"
#. module: product_ux
-#: model_terms:ir.ui.view,arch_db:product_ux.product_pricelist_view
-msgid "Elementos de tarifa"
+#. odoo-python
+#: code:addons/product_ux/models/res_config_settings.py:0
+msgid ""
+"\"%(name)s\" is a %(type)s field. Only text fields and relations can be "
+"searched."
+msgstr ""
+
+#. module: product_ux
+#. odoo-python
+#: code:addons/product_ux/models/res_config_settings.py:0
+msgid "\"%s\" cannot be searched: it is neither stored nor has a search method."
+msgstr ""
+
+#. module: product_ux
+#. odoo-python
+#: code:addons/product_ux/models/res_config_settings.py:0
+msgid ""
+"\"%s\" is an HTML field. Searching inside HTML is the main cause of slow "
+"searches: use a text field."
+msgstr ""
+
+#. module: product_ux
+#. odoo-python
+#: code:addons/product_ux/models/res_config_settings.py:0
+msgid "\"%s\" is an attachment, it cannot be searched."
+msgstr ""
+
+#. module: product_ux
+#. odoo-python
+#: code:addons/product_ux/models/res_config_settings.py:0
+msgid "\"%s\" is not a relational field, the path cannot be followed."
+msgstr ""
+
+#. module: product_ux
+#. odoo-python
+#: code:addons/product_ux/models/res_config_settings.py:0
+msgid "\"%s\" is restricted by groups you do not have."
+msgstr ""
+
+#. module: product_ux
+#: model:ir.module.module,description:base.module_product_ux
+msgid ""
+".. |company| replace:: ADHOC SA\n"
+"\n"
+".. |company_logo| image:: https://raw.githubusercontent.com/ingadhoc/maintainer-tools/master/resources/adhoc-logo.png\n"
+" :alt: ADHOC SA\n"
+" :target: https://www.adhoc.com.ar\n"
+"\n"
+".. |icon| image:: https://raw.githubusercontent.com/ingadhoc/maintainer-tools/master/resources/adhoc-icon.png\n"
+"\n"
+".. image:: https://img.shields.io/badge/license-AGPL--3-blue.png\n"
+" :target: https://www.gnu.org/licenses/agpl\n"
+" :alt: License: AGPL-3\n"
+"\n"
+"=================\n"
+"Product Usability\n"
+"=================\n"
+"\n"
+"Several Improvements to products:\n"
+"\n"
+"#. Now the field \"Active\" (Archive) keeps tracking in chatter.\n"
+"#. Incorporates the possibility to search a product by the vendor product code in the product.template and product.product models.\n"
+"#. Add smart button from pricelist to pricelists items, with the number of items, and search and group the items by what they apply on and by product category.\n"
+"#. Surcharge pricelist rules are rounded by default to the \"Product Price\" precision (discount rules are not, so sales keep showing the discount).\n"
+"#. Add product warranty field that was deprecated by odoo.\n"
+"#. Add new field \"Description\" on product UOMs, shown in their list and form and used when searching them.\n"
+"#. Incorporates the possibility to search the Pricelist Price of products in the tree view.\n"
+"#. Renders the product labels (Dymo and sheets) in batches of pages so large print jobs (thousands of labels) do not crash wkhtmltopdf by memory.\n"
+"#. Lets a consultant choose up to 3 extra product fields in Settings, so the autocomplete of the product field also finds by them. Several words, in any order and spread across those fields, are resolved in a single query. Without configuration the search behaves exactly like Odoo. Fields that Odoo cannot search (HTML, attachments, neither stored nor searchable, or that the user configuring them cannot read, on any step of the path) are rejected when saving, and a path that reaches a model the user cannot read is skipped for that user instead of breaking the autocomplete. Be careful on a very large catalogue: the extra query reads it whole, so measure before turning this on.\n"
+"\n"
+"Installation\n"
+"============\n"
+"\n"
+"To install this module, you need to:\n"
+"\n"
+"#. Just install this module.\n"
+"\n"
+"Configuration\n"
+"=============\n"
+"\n"
+"To configure this module, you need to:\n"
+"\n"
+"#. Optionally set the system parameter ``product_ux.dymo_label_batch_size`` (default 200) to tune how many labels are rendered per wkhtmltopdf call (on sheets it is rounded down to whole pages, at least one). Set it to 0 to render the labels in a single call.\n"
+"\n"
+"Usage\n"
+"=====\n"
+"\n"
+"To use this module, you need to:\n"
+"\n"
+"\n"
+".. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas\n"
+" :alt: Try me on Runbot\n"
+" :target: http://runbot.adhoc.com.ar/\n"
+"\n"
+"Bug Tracker\n"
+"===========\n"
+"\n"
+"Bugs are tracked on `GitHub Issues\n"
+"`_. In case of trouble, please\n"
+"check there if your issue has already been reported. If you spotted it first,\n"
+"help us smashing it by providing a detailed and welcomed feedback.\n"
+"\n"
+"Credits\n"
+"=======\n"
+"\n"
+"Images\n"
+"------\n"
+"\n"
+"* |company| |icon|\n"
+"\n"
+"Contributors\n"
+"------------\n"
+"\n"
+"Maintainer\n"
+"----------\n"
+"\n"
+"|company_logo|\n"
+"\n"
+"This module is maintained by the |company|.\n"
+"\n"
+"To contribute to this module, please visit https://www.adhoc.com.ar.\n"
msgstr ""
#. module: product_ux
@@ -27,8 +146,8 @@ msgid "Active"
msgstr ""
#. module: product_ux
-#: model:ir.model.fields,field_description:product_ux.field_product_pricelist_item__applied_description
-msgid "Applied Description"
+#: model_terms:ir.ui.view,arch_db:product_ux.res_config_settings_view_form
+msgid "Advanced Search Fields"
msgstr ""
#. module: product_ux
@@ -36,6 +155,11 @@ msgstr ""
msgid "Apply On"
msgstr ""
+#. module: product_ux
+#: model:ir.model,name:product_ux.model_res_config_settings
+msgid "Config Settings"
+msgstr ""
+
#. module: product_ux
#: model:ir.model.fields,field_description:product_ux.field_uom_uom__description
msgid "Description"
@@ -43,6 +167,7 @@ msgstr ""
#. module: product_ux
#: model:ir.model.fields,field_description:product_ux.field_ir_actions_report__display_name
+#: model:ir.model.fields,field_description:product_ux.field_product_pricelist__display_name
#: model:ir.model.fields,field_description:product_ux.field_product_pricelist_item__display_name
#: model:ir.model.fields,field_description:product_ux.field_product_product__display_name
#: model:ir.model.fields,field_description:product_ux.field_product_template__display_name
@@ -50,18 +175,35 @@ msgstr ""
msgid "Display Name"
msgstr ""
+#. module: product_ux
+#: model:ir.model,name:product_ux.model_product_search_mixin
+msgid "Extended Product Search"
+msgstr ""
+
#. module: product_ux
#. odoo-python
-#: code:addons/product_ux/migrations/18.0.1.4.0/post-migration.py:0
-msgid ""
-"Hicimos una actualización automática para mejorar la precisión de las listas"
-" de precios. En las reglas de listas de precio tipo \"fórmula\" que no "
-"tenían una precisión de redondeo definida, aplicamos la precisión de precios"
-" configurada en su base de datos. Se ajustaron %(count)s reglas de precios."
+#: code:addons/product_ux/models/res_config_settings.py:0
+msgid "Field \"%(path)s\" does not exist on %(model)s."
+msgstr ""
+
+#. module: product_ux
+#: model:ir.model.fields,field_description:product_ux.field_res_config_settings__product_search_field_1
+msgid "Field 1"
+msgstr ""
+
+#. module: product_ux
+#: model:ir.model.fields,field_description:product_ux.field_res_config_settings__product_search_field_2
+msgid "Field 2"
+msgstr ""
+
+#. module: product_ux
+#: model:ir.model.fields,field_description:product_ux.field_res_config_settings__product_search_field_3
+msgid "Field 3"
msgstr ""
#. module: product_ux
#: model:ir.model.fields,field_description:product_ux.field_ir_actions_report__id
+#: model:ir.model.fields,field_description:product_ux.field_product_pricelist__id
#: model:ir.model.fields,field_description:product_ux.field_product_pricelist_item__id
#: model:ir.model.fields,field_description:product_ux.field_product_product__id
#: model:ir.model.fields,field_description:product_ux.field_product_template__id
@@ -85,13 +227,19 @@ msgid ""
msgstr ""
#. module: product_ux
+#: model:ir.model.fields,field_description:product_ux.field_product_pricelist__item_count
+msgid "Item Count"
+msgstr ""
+
+#. module: product_ux
+#: model:ir.model,name:product_ux.model_product_pricelist
#: model:ir.model.fields,field_description:product_ux.field_product_product__pricelist_id
#: model:ir.model.fields,field_description:product_ux.field_product_template__pricelist_id
msgid "Pricelist"
msgstr ""
#. module: product_ux
-#: model_terms:ir.ui.view,arch_db:product_ux.product_pricelist_item_search_view
+#: model_terms:ir.ui.view,arch_db:product_ux.product_pricelist_view
msgid "Pricelist Items"
msgstr ""
@@ -114,7 +262,6 @@ msgstr ""
#. module: product_ux
#: model:ir.model,name:product_ux.model_product_template
-#: model_terms:ir.ui.view,arch_db:product_ux.product_pricelist_item_search_view
msgid "Product"
msgstr ""
@@ -124,8 +271,23 @@ msgid "Product Category"
msgstr ""
#. module: product_ux
-#: model_terms:ir.ui.view,arch_db:product_ux.product_pricelist_item_search_view
-msgid "Product Template"
+#: model_terms:ir.ui.view,arch_db:product_ux.res_config_settings_view_form
+msgid "Product Search"
+msgstr ""
+
+#. module: product_ux
+#: model:ir.model.fields,field_description:product_ux.field_res_config_settings__product_search_fields
+msgid "Product Search Fields"
+msgstr ""
+
+#. module: product_ux
+#: model:ir.model.fields,field_description:product_ux.field_res_config_settings__product_search_model
+msgid "Product Search Model"
+msgstr ""
+
+#. module: product_ux
+#: model:ir.module.module,shortdesc:base.module_product_ux
+msgid "Product UX"
msgstr ""
#. module: product_ux
@@ -148,6 +310,16 @@ msgstr ""
msgid "Report Action"
msgstr ""
+#. module: product_ux
+#: model:ir.model.fields,field_description:product_ux.field_res_config_settings__product_extend_search_fields
+msgid "Search products by more fields"
+msgstr ""
+
+#. module: product_ux
+#: model_terms:ir.ui.view,arch_db:product_ux.res_config_settings_view_form
+msgid "Select a field"
+msgstr ""
+
#. module: product_ux
#: model:ir.model.fields,help:product_ux.field_product_product__sellers_product_code
#: model:ir.model.fields,help:product_ux.field_product_template__sellers_product_code
@@ -168,6 +340,14 @@ msgstr ""
msgid "Warranty"
msgstr ""
+#. module: product_ux
+#: model_terms:ir.ui.view,arch_db:product_ux.res_config_settings_view_form
+msgid ""
+"When picking a product in a sales or purchase line, also find it by the "
+"fields you choose. It does not change the Search... box of the product "
+"lists."
+msgstr ""
+
#. module: product_ux
#: model_terms:ir.ui.view,arch_db:product_ux.product_template_only_form_view
msgid "months"
diff --git a/product_ux/migrations/18.0.1.4.0/post-migration.py b/product_ux/migrations/18.0.1.4.0/post-migration.py
deleted file mode 100644
index 30e44ece5..000000000
--- a/product_ux/migrations/18.0.1.4.0/post-migration.py
+++ /dev/null
@@ -1,26 +0,0 @@
-from odoo import SUPERUSER_ID, _, api
-
-
-def migrate(cr, version):
- env = api.Environment(cr, SUPERUSER_ID, {})
- pricelist_item = env["product.pricelist.item"].search(
- [("compute_price", "=", "formula"), ("price_round", "=", 0.0)]
- )
- precision = env["decimal.precision"].sudo().precision_get("Product Price")
- rounding = 10**-precision
- pricelist_item.write({"price_round": rounding})
- count_rules = len(pricelist_item)
-
- channel_admin = env.ref("mail.channel_admin", raise_if_not_found=False)
- if channel_admin and count_rules > 0:
- channel_admin.message_post(
- body=_(
- "Hicimos una actualización automática para mejorar la precisión de las listas de precios. "
- 'En las reglas de listas de precio tipo "fórmula" que no tenían una precisión de redondeo definida, '
- "aplicamos la precisión de precios configurada en su base de datos. "
- "Se ajustaron %(count)s reglas de precios."
- )
- % {"count": count_rules},
- message_type="comment",
- subtype_xmlid="mail.mt_comment",
- )
diff --git a/product_ux/models/__init__.py b/product_ux/models/__init__.py
index 00c3e5513..005ba0f07 100644
--- a/product_ux/models/__init__.py
+++ b/product_ux/models/__init__.py
@@ -2,8 +2,11 @@
# For copyright and license notices, see __manifest__.py file in module root
# directory
##############################################################################
+from . import product_search_mixin
from . import product_template
from . import product_product
from . import uom_uom
+from . import product_pricelist
from . import product_pricelist_item
from . import ir_actions_report
+from . import res_config_settings
diff --git a/product_ux/models/ir_actions_report.py b/product_ux/models/ir_actions_report.py
index 3471c52a9..afafe5a04 100644
--- a/product_ux/models/ir_actions_report.py
+++ b/product_ux/models/ir_actions_report.py
@@ -5,11 +5,13 @@
from odoo import models
from odoo.tools.pdf import merge_pdf
-# The Dymo report prints one label per page. A picking with thousands of units
-# produces a PDF of thousands of pages and wkhtmltopdf dies out of memory
-# (signal 11 -> return code -11). We split the render into batches and merge.
-DYMO_REPORT = "product.report_producttemplatelabel_dymo"
-DEFAULT_BATCH_SIZE = 200
+# Each label report is rendered by a single wkhtmltopdf call. A picking with
+# thousands of units produces thousands of pages and wkhtmltopdf dies out of
+# memory (signal 11 -> return code -11). We split the render into batches of
+# labels, cut at whole pages, and merge the PDFs.
+DYMO_REPORT = "product.report_product_label_dymo" # one label per page
+SHEET_REPORT = "product.report_product_label_pdf" # rows x columns labels per page
+DEFAULT_BATCH_SIZE = 200 # labels per wkhtmltopdf call (whole pages on sheets)
class IrActionsReport(models.Model):
@@ -17,63 +19,54 @@ class IrActionsReport(models.Model):
def _render_qweb_pdf(self, report_ref, res_ids=None, data=None):
report = self._get_report(report_ref)
- if report.report_name != DYMO_REPORT or not data:
+ if report.report_name not in (DYMO_REPORT, SHEET_REPORT) or not data or not data.get("labels"):
return super()._render_qweb_pdf(report_ref, res_ids=res_ids, data=data)
- batch_size = int(
- self.env["ir.config_parameter"].sudo().get_param("product_ux.dymo_label_batch_size", DEFAULT_BATCH_SIZE)
+ batch_size = (
+ self.env["ir.config_parameter"].sudo().get_int("product_ux.dymo_label_batch_size", DEFAULT_BATCH_SIZE)
)
if batch_size <= 0:
return super()._render_qweb_pdf(report_ref, res_ids=res_ids, data=data)
- batches = self._dymo_label_batches(data, batch_size)
+ if report.report_name == DYMO_REPORT:
+ labels_per_page = 1
+ else:
+ layout = data.get("layout") or {}
+ labels_per_page = max(int(layout.get("rows") or 1) * int(layout.get("columns") or 1), 1)
+ batches = self._label_batches(data, max(batch_size // labels_per_page, 1) * labels_per_page)
if len(batches) <= 1:
return super()._render_qweb_pdf(report_ref, res_ids=res_ids, data=data)
pdf_contents = []
for batch_data in batches:
- content, _dummy = super()._render_qweb_pdf(report_ref, res_ids=res_ids, data=batch_data)
+ content, report_type = super()._render_qweb_pdf(report_ref, res_ids=res_ids, data=batch_data)
+ if report_type != "pdf":
+ # Rendered as HTML (tests do it): there is no wkhtmltopdf call to split.
+ return super()._render_qweb_pdf(report_ref, res_ids=res_ids, data=data)
pdf_contents.append(content)
return merge_pdf(pdf_contents), "pdf"
- def _dymo_label_batches(self, data, batch_size):
- """Split the label data into chunks of at most ``batch_size`` labels,
+ def _label_batches(self, data, labels_per_batch):
+ """Split the labels into chunks of at most ``labels_per_batch`` labels,
so each chunk is rendered by an independent (small) wkhtmltopdf call.
The data shape is the one built by the ``product.label.layout`` wizard:
- ``quantity_by_product`` ({product: qty}) plus an optional
- ``custom_barcodes`` ({product: [(barcode, qty)]}) for lots/serials.
+ ``labels`` is a list of dicts, each one printed ``copies`` times. A label
+ with more copies than fit in a chunk is split between chunks. Pass a
+ multiple of the labels per page, so every chunk but the last one holds
+ whole pages and the merged PDF has the same pages as a single render.
"""
- qty_by_product = data.get("quantity_by_product") or {}
- custom_barcodes = data.get("custom_barcodes") or {}
-
- # Flatten everything to a stream of (source, key, barcode, qty) groups.
- units = [("qty", key, None, int(q)) for key, q in qty_by_product.items()]
- for key, barcode_qtys in custom_barcodes.items():
- units += [("custom", key, bc, int(q)) for bc, q in barcode_qtys]
-
- batches = []
- cur_qty, cur_custom, count = {}, {}, 0
- for source, key, barcode, qty in units:
- while qty > 0:
- take = min(qty, batch_size - count)
- if source == "qty":
- cur_qty[key] = cur_qty.get(key, 0) + take
- else:
- cur_custom.setdefault(key, []).append((barcode, take))
+ batches, batch, count = [], [], 0
+ for label in data["labels"]:
+ copies = int(label.get("copies", 1))
+ while copies > 0:
+ take = min(copies, labels_per_batch - count)
+ batch.append(dict(label, copies=take))
count += take
- qty -= take
- if count >= batch_size:
- batches.append((cur_qty, cur_custom))
- cur_qty, cur_custom, count = {}, {}, 0
- if count:
- batches.append((cur_qty, cur_custom))
-
- result = []
- for b_qty, b_custom in batches:
- batch_data = dict(data)
- batch_data["quantity_by_product"] = b_qty
- if custom_barcodes:
- batch_data["custom_barcodes"] = b_custom
- result.append(batch_data)
- return result
+ copies -= take
+ if count == labels_per_batch:
+ batches.append(batch)
+ batch, count = [], 0
+ if batch:
+ batches.append(batch)
+ return [dict(data, labels=batch) for batch in batches]
diff --git a/product_ux/models/product_pricelist.py b/product_ux/models/product_pricelist.py
new file mode 100644
index 000000000..64dbe711a
--- /dev/null
+++ b/product_ux/models/product_pricelist.py
@@ -0,0 +1,16 @@
+from odoo import fields, models
+
+
+class ProductPricelist(models.Model):
+ _inherit = "product.pricelist"
+
+ item_count = fields.Integer(compute="_compute_item_count")
+
+ def _compute_item_count(self):
+ counts = dict(
+ self.env["product.pricelist.item"]._read_group(
+ [("pricelist_id", "in", self.ids)], ["pricelist_id"], ["__count"]
+ )
+ )
+ for pricelist in self:
+ pricelist.item_count = counts.get(pricelist, 0)
diff --git a/product_ux/models/product_pricelist_item.py b/product_ux/models/product_pricelist_item.py
index 8a5b69938..c12e53587 100644
--- a/product_ux/models/product_pricelist_item.py
+++ b/product_ux/models/product_pricelist_item.py
@@ -1,25 +1,14 @@
-from odoo import api, fields, models
+from odoo import api, models
-class PicelistItem(models.Model):
+class ProductPricelistItem(models.Model):
_inherit = "product.pricelist.item"
- applied_description = fields.Char(compute="_compute_applied_description")
-
- @api.depends("applied_on", "product_tmpl_id", "product_id", "categ_id")
- def _compute_applied_description(self):
- for rec in self:
- if rec.applied_on == "0_product_variant":
- rec.applied_description = rec.product_id.display_name
- elif rec.applied_on == "1_product":
- rec.applied_description = rec.product_tmpl_id.display_name
- elif rec.applied_on == "2_product_category":
- rec.applied_description = rec.categ_id.display_name
- else:
- rec.applied_description = False
-
@api.onchange("compute_price")
def _onchange_compute_price(self):
+ """Surcharge rules round by default to the price precision, unless a
+ rounding was already set. Discount rules are left as in Odoo: a rounded
+ discount is no longer a plain discount, and sales stop showing it."""
super()._onchange_compute_price()
- if self.compute_price == "formula":
+ if self.compute_price == "markup" and not self.price_round:
self.price_round = 10 ** -self.env["decimal.precision"].sudo().precision_get("Product Price")
diff --git a/product_ux/models/product_product.py b/product_ux/models/product_product.py
index ca78b7c5b..46855c0c2 100644
--- a/product_ux/models/product_product.py
+++ b/product_ux/models/product_product.py
@@ -6,7 +6,11 @@
class ProductProduct(models.Model):
- _inherit = "product.product"
+ _name = "product.product"
+ _inherit = ["product.product", "product.search.mixin"]
+
+ # the configured paths start at product.template
+ _search_path_prefix = "product_tmpl_id."
active = fields.Boolean(tracking=True)
pricelist_price = fields.Float(compute="_compute_product_pricelist_price", digits="Product Price")
@@ -15,3 +19,8 @@ class ProductProduct(models.Model):
def _compute_product_pricelist_price(self):
for product in self:
product.pricelist_price = product._get_contextual_price()
+
+ @api.model
+ def name_search(self, name="", domain=None, operator="ilike", limit=100):
+ results = super().name_search(name, domain, operator, limit)
+ return self._extend_name_search(results, name, domain, operator, limit)
diff --git a/product_ux/models/product_search_mixin.py b/product_ux/models/product_search_mixin.py
new file mode 100644
index 000000000..93e0e535d
--- /dev/null
+++ b/product_ux/models/product_search_mixin.py
@@ -0,0 +1,89 @@
+##############################################################################
+# For copyright and license notices, see __manifest__.py file in module root
+# directory
+##############################################################################
+from ast import literal_eval
+
+from odoo import api, models
+from odoo.fields import Domain
+
+LIKE_OPERATORS = ("ilike", "like")
+# below three characters a trigram index cannot serve the query
+MIN_CHARS = 3
+MAX_FIELDS = 3
+# always searched, so a word of the term can match the product itself
+NATIVE_PATHS = ("name", "default_code")
+PARAM_ENABLED = "product_ux.extend_search_fields"
+PARAM_FIELDS = "product_ux.search_fields"
+
+
+def parse_paths(value):
+ """The parameter as a list of paths. Content nobody can read is no configuration."""
+ try:
+ paths = literal_eval(value or "[]")
+ except (ValueError, SyntaxError):
+ return []
+ if not isinstance(paths, list):
+ return []
+ return [path.strip() for path in paths if isinstance(path, str) and path.strip()]
+
+
+def configured_paths(env):
+ """Paths from product.template the consultant configured, or nothing."""
+ params = env["ir.config_parameter"].sudo()
+ if not params.get_bool(PARAM_ENABLED):
+ return ()
+ # the boxes cap it, so a hand edited parameter cannot widen the search either
+ return tuple(parse_paths(params.get_str(PARAM_FIELDS))[:MAX_FIELDS])
+
+
+class ProductSearchMixin(models.AbstractModel):
+ _name = "product.search.mixin"
+ _description = "Extended Product Search"
+
+ # prefix needed to reach a product.template field from this model
+ _search_path_prefix = ""
+
+ @api.model
+ def _extended_search_paths(self):
+ paths = tuple(path for path in configured_paths(self.env) if self._can_search_path(path))
+ if not paths:
+ return ()
+ return NATIVE_PATHS + tuple(self._search_path_prefix + path for path in paths)
+
+ @api.model
+ def _can_search_path(self, path):
+ """A path this user cannot reach is skipped, never raised: the rights are of whoever searches."""
+ model = self.env["product.template"]
+ names = path.split(".")
+ for index, name in enumerate(names):
+ field = model._fields.get(name)
+ if field is None or not model._has_field_access(field, "read"):
+ return False
+ if not field.relational:
+ return index == len(names) - 1
+ model = self.env[field.comodel_name]
+ if not model.has_access("read"):
+ return False
+ return True
+
+ @api.model
+ def _extended_search_domain(self, term):
+ """Every word, in any order and on any of the paths, in a single domain."""
+ paths = self._extended_search_paths()
+ if not paths or not isinstance(term, str) or len(term.strip()) < MIN_CHARS:
+ return None
+ return Domain.AND([Domain.OR([Domain(path, "ilike", word) for path in paths]) for word in term.split()])
+
+ @api.model
+ def _extend_name_search(self, results, term, domain, operator, limit):
+ """Complete the native result, only if it did not fill the limit."""
+ if operator not in LIKE_OPERATORS or (limit and len(results) >= limit):
+ return results
+ extra = self._extended_search_domain(term)
+ if extra is None:
+ return results
+ ids = [res[0] for res in results]
+ full_domain = Domain.AND([Domain(domain or Domain.TRUE), extra, Domain("id", "not in", ids)])
+ records = self.browse(self._search(full_domain, limit=limit and limit - len(ids)))
+ return results + [(record.id, record.display_name) for record in records]
diff --git a/product_ux/models/product_template.py b/product_ux/models/product_template.py
index 79c14543a..992457133 100644
--- a/product_ux/models/product_template.py
+++ b/product_ux/models/product_template.py
@@ -6,7 +6,8 @@
class ProductTemplate(models.Model):
- _inherit = "product.template"
+ _name = "product.template"
+ _inherit = ["product.template", "product.search.mixin"]
active = fields.Boolean(tracking=True)
sellers_product_code = fields.Char(
@@ -29,20 +30,22 @@ def _compute_product_pricelist_price(self):
def _get_contextual_pricelist(self):
"""Re agregamos compatibilidad con que la lista de precios se mande como name o como lista en el contexto
- Basicamente esto de acá https://github.com/odoo/odoo/blob/13.0/addons/product/models/product_template.py#L213
- Si viene lista obtenemos el primer elemento
- Luego, si ese elemento es string buscamos a traves de name search.
- Para otros casos devolvemos super (debería ser un ID)
+ (la vista de búsqueda manda lo que escribe el usuario), como hasta 13.0:
+ https://github.com/odoo/odoo/blob/13.0/addons/product/models/product_template.py#L213
+ Si viene lista tomamos el primer elemento; si es string, la buscamos por nombre.
+ Para otros casos devolvemos super (debería ser un ID).
"""
- pricelist_id_or_name = self.env.context.get("pricelist")
- if isinstance(pricelist_id_or_name, list):
- pricelist_id_or_name = pricelist_id_or_name[0]
- # Si viene una lista en pricelist actualizamos contexto para que si se llama super, este reciba un entero
- self = self.with_context(pricelist=pricelist_id_or_name)
- if isinstance(pricelist_id_or_name, str):
- pricelist_data = self.env["product.pricelist"].name_search(pricelist_id_or_name, operator="=", limit=1)
- if pricelist_data:
- return self.env["product.pricelist"].browse(pricelist_data[0][0])
- else:
- return self.env["product.pricelist"]
+ pricelist = self.env.context.get("pricelist")
+ if isinstance(pricelist, list):
+ pricelist = pricelist and pricelist[0]
+ # super espera un entero en el contexto
+ self = self.with_context(pricelist=pricelist)
+ if isinstance(pricelist, str):
+ found = self.env["product.pricelist"].name_search(pricelist, operator="=", limit=1)
+ return self.env["product.pricelist"].browse(found and found[0][0])
return super()._get_contextual_pricelist()
+
+ @api.model
+ def name_search(self, name="", domain=None, operator="ilike", limit=100):
+ results = super().name_search(name, domain, operator, limit)
+ return self._extend_name_search(results, name, domain, operator, limit)
diff --git a/product_ux/models/res_config_settings.py b/product_ux/models/res_config_settings.py
new file mode 100644
index 000000000..98882995f
--- /dev/null
+++ b/product_ux/models/res_config_settings.py
@@ -0,0 +1,119 @@
+##############################################################################
+# For copyright and license notices, see __manifest__.py file in module root
+# directory
+##############################################################################
+from odoo import api, fields, models
+from odoo.exceptions import ValidationError
+
+from .product_search_mixin import MAX_FIELDS, PARAM_ENABLED, PARAM_FIELDS, parse_paths
+
+# a relation as the last step searches by the display name of its records
+SEARCHABLE_TYPES = ("char", "text")
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = "res.config.settings"
+
+ product_extend_search_fields = fields.Boolean(
+ string="Search products by more fields",
+ config_parameter=PARAM_ENABLED,
+ )
+ # same format as the eCommerce parameter: a list of paths from product.template
+ product_search_fields = fields.Char(default="[]", config_parameter=PARAM_FIELDS)
+ # the field_selector widget reads the model to browse from another field
+ product_search_model = fields.Char(default="product.template", readonly=True)
+ product_search_field_1 = fields.Char(
+ string="Field 1",
+ compute="_compute_product_search_field",
+ inverse="_inverse_product_search_field",
+ readonly=False,
+ )
+ product_search_field_2 = fields.Char(
+ string="Field 2",
+ compute="_compute_product_search_field",
+ inverse="_inverse_product_search_field",
+ readonly=False,
+ )
+ product_search_field_3 = fields.Char(
+ string="Field 3",
+ compute="_compute_product_search_field",
+ inverse="_inverse_product_search_field",
+ readonly=False,
+ )
+
+ def _search_fields_list(self):
+ self.ensure_one()
+ return parse_paths(self.product_search_fields)
+
+ @api.depends("product_search_fields")
+ def _compute_product_search_field(self):
+ for rec in self:
+ paths = rec._search_fields_list()
+ for index in range(MAX_FIELDS):
+ rec["product_search_field_%s" % (index + 1)] = paths[index] if index < len(paths) else False
+
+ def _inverse_product_search_field(self):
+ for rec in self:
+ boxes = [rec["product_search_field_%s" % (index + 1)] for index in range(MAX_FIELDS)]
+ paths = [path.strip() for path in boxes if path and path.strip()]
+ # a parameter nobody edited here is left alone, so saving any other setting never fails
+ if paths == rec._search_fields_list()[:MAX_FIELDS]:
+ continue
+ for path in paths:
+ rec._check_search_path(path)
+ rec.product_search_fields = repr(paths)
+
+ def _check_search_path(self, path):
+ """Reject what Odoo cannot search, or what would degrade the search."""
+ model = self.env["product.template"]
+ parts = path.split(".")
+ for index, name in enumerate(parts):
+ field = model._fields.get(name)
+ if field is None:
+ raise ValidationError(
+ self.env._(
+ 'Field "%(path)s" does not exist on %(model)s.',
+ path=path,
+ model=model._name,
+ )
+ )
+ self._check_searchable(model, field)
+ if index == len(parts) - 1:
+ self._check_last_step(field)
+ elif not field.relational:
+ raise ValidationError(self.env._('"%s" is not a relational field, the path cannot be followed.', name))
+ else:
+ model = self.env[field.comodel_name]
+
+ def _check_searchable(self, model, field):
+ """Every step has to be searchable on its own, or the domain breaks in SQL."""
+ if not field._description_searchable:
+ raise ValidationError(
+ self.env._(
+ '"%s" cannot be searched: it is neither stored nor has a search method.',
+ field.string,
+ )
+ )
+ # the same criterion as the search: a field this user cannot read is not configurable
+ if not model._has_field_access(field, "read"):
+ raise ValidationError(self.env._('"%s" is restricted by groups you do not have.', field.string))
+
+ def _check_last_step(self, field):
+ if field.type == "html":
+ raise ValidationError(
+ self.env._(
+ '"%s" is an HTML field. Searching inside HTML is the main cause of '
+ "slow searches: use a text field.",
+ field.string,
+ )
+ )
+ if field.type in ("binary", "image"):
+ raise ValidationError(self.env._('"%s" is an attachment, it cannot be searched.', field.string))
+ if field.type not in SEARCHABLE_TYPES and not field.relational:
+ raise ValidationError(
+ self.env._(
+ '"%(name)s" is a %(type)s field. Only text fields and relations can be searched.',
+ name=field.string,
+ type=field.type,
+ )
+ )
diff --git a/product_ux/tests/__init__.py b/product_ux/tests/__init__.py
new file mode 100644
index 000000000..33fca57f2
--- /dev/null
+++ b/product_ux/tests/__init__.py
@@ -0,0 +1,2 @@
+from . import test_product_ux
+from . import test_product_search
diff --git a/product_ux/tests/test_product_search.py b/product_ux/tests/test_product_search.py
new file mode 100644
index 000000000..985725251
--- /dev/null
+++ b/product_ux/tests/test_product_search.py
@@ -0,0 +1,165 @@
+##############################################################################
+# For copyright and license notices, see __manifest__.py file in module root
+# directory
+##############################################################################
+from unittest.mock import patch
+
+from odoo.exceptions import ValidationError
+from odoo.tests.common import TransactionCase, new_test_user, tagged
+
+from ..models.product_search_mixin import PARAM_ENABLED, PARAM_FIELDS
+
+
+@tagged("post_install", "-at_install")
+class TestProductSearch(TransactionCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.Product = cls.env["product.product"]
+ cls.Settings = cls.env["res.config.settings"]
+ cls.tag = cls.env["product.tag"].create({"name": "amoladora chica"})
+ cls.product = cls.Product.create({"name": "Angular Grinder", "default_code": "GRI-01", "barcode": "779-XYZ"})
+ cls.product.product_tmpl_id.product_tag_ids = cls.tag
+ cls.env["product.supplierinfo"].create(
+ {
+ "partner_id": cls.env["res.partner"].create({"name": "Vendor"}).id,
+ "product_tmpl_id": cls.product.product_tmpl_id.id,
+ "product_code": "V-778",
+ }
+ )
+ cls.other = cls.Product.create({"name": "Fixed Grinder", "default_code": "GRI-02"})
+
+ def _configure(self, *paths):
+ params = self.env["ir.config_parameter"].sudo()
+ params.set_bool(PARAM_ENABLED, bool(paths))
+ params.set_str(PARAM_FIELDS, repr(list(paths)))
+
+ def _settings_with(self, *paths, user=None):
+ """Save the Settings page as the browser does: through the boxes."""
+ values = {"product_extend_search_fields": True}
+ for index, path in enumerate(paths):
+ values["product_search_field_%s" % (index + 1)] = path
+ settings = self.Settings.with_user(user) if user else self.Settings
+ return settings.create(values)
+
+ def _name_search_ids(self, model, term, **kwargs):
+ return [res[0] for res in self.env[model].name_search(term, **kwargs)]
+
+ def test_settings_round_trip(self):
+ """The three boxes are saved in the parameter and read back from it."""
+ settings = self._settings_with("product_tag_ids.name", "seller_ids.product_code")
+ settings.execute()
+ param = self.env["ir.config_parameter"].sudo().get_str(PARAM_FIELDS)
+ self.assertEqual(param, repr(["product_tag_ids.name", "seller_ids.product_code"]))
+ reopened = self.Settings.create({})
+ self.assertEqual(reopened.product_search_field_1, "product_tag_ids.name")
+ self.assertFalse(reopened.product_search_field_3)
+
+ def test_bad_parameter_does_not_break_the_settings(self):
+ """A hand edited parameter must not stop the Settings page from rendering."""
+ self.assertFalse(self.Settings.new({"product_search_fields": "not a list"}).product_search_field_1)
+
+ def test_extra_paths_in_the_parameter_block_nothing(self):
+ """Four paths in the parameter: saving any setting still works, the search takes three."""
+ self._configure("product_tag_ids.name", "seller_ids.product_code", "categ_id", "description_sale")
+ self.Settings.create({}).execute()
+ self.assertEqual(len(self.Product._extended_search_paths()), 5)
+
+ def test_finds_by_the_configured_fields(self):
+ """Tag and vendor code, on the variant and on the template, in any order."""
+ self._configure("product_tag_ids.name", "seller_ids.product_code")
+ self.assertIn(self.product.id, self._name_search_ids("product.product", "amoladora chica"))
+ self.assertIn(
+ self.product.product_tmpl_id.id,
+ self._name_search_ids("product.template", "amoladora chica"),
+ )
+ self.assertIn(self.product.id, self._name_search_ids("product.product", "V-778"))
+ self.assertIn(self.product.id, self._name_search_ids("product.product", "chica angular"))
+ self.assertNotIn(self.product.id, self._name_search_ids("product.product", "chica missing"))
+
+ def test_accepts_what_odoo_can_search(self):
+ """Not stored but searchable, and a relation as the last step, both work."""
+ for path, term in [
+ ("barcode", "779-X"),
+ ("sellers_product_code", "V-778"),
+ ("product_tag_ids", "amoladora chica"),
+ ]:
+ self._settings_with(path).execute()
+ self.assertIn(self.product.id, self._name_search_ids("product.product", term), path)
+
+ def test_guards(self):
+ """Nothing runs without configuration, with the switch off, under three characters or on an exact search."""
+ self._configure()
+ self.assertNotIn(self.product.id, self._name_search_ids("product.product", "amoladora"))
+ self.assertIn(self.product.id, self._name_search_ids("product.product", "Angular"))
+ self._configure("product_tag_ids.name")
+ self.assertNotIn(self.product.id, self._name_search_ids("product.product", "am"))
+ self.assertFalse(self._name_search_ids("product.product", "amoladora chica", operator="="))
+ with patch.object(type(self.Product), "_extended_search_domain", autospec=True) as spy:
+ self.Product.name_search("Grinder", limit=1)
+ spy.assert_not_called()
+ # a parameter edited by hand holds text, and "False" is not a truth
+ self.env["ir.config_parameter"].sudo().set_str(PARAM_ENABLED, "False")
+ self.assertNotIn(self.product.id, self._name_search_ids("product.product", "amoladora chica"))
+
+ def test_single_query(self):
+ """Every word on every field costs one query, not one per field."""
+ self._configure("product_tag_ids.name")
+ product_class = type(self.Product)
+ original_search = product_class._search
+ calls = []
+
+ def counting_search(self, *args, **kwargs):
+ calls.append(args)
+ return original_search(self, *args, **kwargs)
+
+ with patch.object(product_class, "_search", counting_search):
+ self.Product._extend_name_search([], "amoladora chica", None, "ilike", 10)
+ # the other calls are the access rule checks on reading the results
+ self.assertEqual(len([args for args in calls if "product_tag_ids" in str(args)]), 1)
+
+ def test_rejects_what_cannot_be_searched(self):
+ """The widget does not filter these, so the inverse has to."""
+ for path in [
+ "description", # html
+ "image_1920", # attachment
+ "there_is_no_such_field",
+ "standard_price", # a number is not worth searching by ilike
+ "product_variant_id.barcode", # the step in the middle is not searchable
+ ]:
+ with self.assertRaises(ValidationError, msg=path):
+ self._settings_with(path)
+
+ def test_groups_on_save_follow_the_user(self):
+ """Same criterion as the search: what counts is whether this user reads the field."""
+ barcode = self.env["product.template"]._fields["barcode"]
+ with patch.object(barcode, "groups", "base.group_user"):
+ self._settings_with("barcode")
+ # a path is only as accessible as its least accessible step
+ user = new_test_user(self.env, login="product_search_admin", groups="base.group_system")
+ seller_ids = self.env["product.template"]._fields["seller_ids"]
+ with patch.object(seller_ids, "groups", "base.group_multi_company"):
+ with self.assertRaises(ValidationError):
+ self._settings_with("seller_ids.product_code", user=user)
+
+ def test_skips_a_path_this_user_cannot_search(self):
+ """What the user cannot reach is skipped, never raised, and the other paths keep working."""
+ self._configure("product_tag_ids.name", "seller_ids.product_code")
+ user = new_test_user(self.env, login="product_search_probe", groups="base.group_user")
+ product = self.Product.with_user(user)
+
+ def ids(term):
+ return [res[0] for res in product.name_search(term)]
+
+ self.assertIn(self.product.id, ids("amoladora chica"))
+ tag_field = self.env["product.template"]._fields["product_tag_ids"]
+ with patch.object(tag_field, "groups", "base.group_system"):
+ self.assertFalse(ids("amoladora chica"))
+ self.assertIn(self.product.id, ids("V-778"))
+ self.env["ir.access"].search([("model_id.model", "=", "product.tag")]).unlink()
+ self.assertFalse(ids("amoladora chica"))
+ self.assertIn(self.product.id, ids("V-778"))
+ # a path the parameter kept but the model no longer has never reaches the SQL either
+ for path in ["no_such_field", "warranty.no_such_field"]:
+ self._configure(path)
+ self.assertFalse(ids("amoladora chica"))
diff --git a/product_ux/tests/test_product_ux.py b/product_ux/tests/test_product_ux.py
new file mode 100644
index 000000000..b481662fa
--- /dev/null
+++ b/product_ux/tests/test_product_ux.py
@@ -0,0 +1,180 @@
+##############################################################################
+# For copyright and license notices, see __manifest__.py file in module root
+# directory
+##############################################################################
+from unittest.mock import patch
+
+from odoo.addons.base.models.ir_actions_report import IrActionsReport
+from odoo.tests import TransactionCase, tagged
+
+
+@tagged("post_install", "-at_install")
+class TestProductUx(TransactionCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.pricelist = cls.env["product.pricelist"].create({"name": "Product UX Wholesale"})
+ cls.template = cls.env["product.template"].create({"name": "Product UX Drill", "list_price": 100.0})
+ cls.product = cls.template.product_variant_id
+ cls.env["product.pricelist.item"].create(
+ {
+ "pricelist_id": cls.pricelist.id,
+ "applied_on": "1_product",
+ "product_tmpl_id": cls.template.id,
+ "compute_price": "discount",
+ "price_discount": 10.0,
+ }
+ )
+ cls.report = cls.env["ir.actions.report"]
+
+ def _labels_data(self, copies, rows=1, columns=1):
+ return {
+ "labels": [{"name": f"Label {i}", "copies": qty} for i, qty in enumerate(copies)],
+ "label_template": "product.barcode_2x7_label",
+ "layout": {"rows": rows, "columns": columns},
+ }
+
+ # Labels in batches
+
+ def test_label_batches_split_copies_and_keep_order(self):
+ """Copies are cut across batches so no batch exceeds the size, and the
+ labels come out in the same order and with the same total copies."""
+ data = self._labels_data([3, 250, 1])
+ batches = self.report._label_batches(data, 100)
+
+ self.assertEqual([sum(label["copies"] for label in batch["labels"]) for batch in batches], [100, 100, 54])
+ flat = [label["name"] for batch in batches for label in batch["labels"] for _copy in range(label["copies"])]
+ self.assertEqual(flat, ["Label 0"] * 3 + ["Label 1"] * 250 + ["Label 2"])
+ self.assertTrue(all(batch["layout"] == data["layout"] for batch in batches))
+ self.assertEqual(len(data["labels"]), 3, "the original data is not modified")
+
+ def test_label_batches_skip_labels_without_copies(self):
+ batches = self.report._label_batches(self._labels_data([0, 2]), 100)
+ self.assertEqual([label["name"] for label in batches[0]["labels"]], ["Label 1"])
+
+ def _render_labels(self, report_xmlid, data):
+ """Render with the base PDF rendering mocked: returns the labels each
+ wkhtmltopdf call got and the merged result."""
+ calls = []
+
+ def fake_render(report_self, report_ref, res_ids=None, data=None):
+ calls.append(sum(label["copies"] for label in data["labels"]))
+ return f"pdf{len(calls)}".encode(), "pdf"
+
+ with (
+ patch.object(IrActionsReport, "_render_qweb_pdf", fake_render),
+ patch(
+ "odoo.addons.product_ux.models.ir_actions_report.merge_pdf",
+ side_effect=lambda contents: b"|".join(contents),
+ ),
+ ):
+ result = self.report._render_qweb_pdf(report_xmlid, data=data)
+ return calls, result
+
+ def test_dymo_labels_render_in_batches_of_pages(self):
+ self.env["ir.config_parameter"].sudo().set_int("product_ux.dymo_label_batch_size", 200)
+ calls, result = self._render_labels("product.action_report_product_label_dymo", self._labels_data([450]))
+ self.assertEqual(calls, [200, 200, 50])
+ self.assertEqual(result, (b"pdf1|pdf2|pdf3", "pdf"))
+
+ def test_sheet_labels_render_in_batches_of_whole_pages(self):
+ """A 2x7 sheet holds 14 labels: a batch of 30 labels is cut to 2 whole pages (28)."""
+ self.env["ir.config_parameter"].sudo().set_int("product_ux.dymo_label_batch_size", 30)
+ calls, _result = self._render_labels(
+ "product.action_report_product_label_pdf", self._labels_data([300], rows=7, columns=2)
+ )
+ self.assertEqual(calls, [28] * 10 + [20])
+
+ def test_sheet_labels_batch_smaller_than_a_page_renders_one_page(self):
+ self.env["ir.config_parameter"].sudo().set_int("product_ux.dymo_label_batch_size", 10)
+ calls, _result = self._render_labels(
+ "product.action_report_product_label_pdf", self._labels_data([30], rows=12, columns=4)
+ )
+ self.assertEqual(calls, [30])
+
+ def test_label_batching_can_be_disabled(self):
+ self.env["ir.config_parameter"].sudo().set_int("product_ux.dymo_label_batch_size", 0)
+ calls, _result = self._render_labels("product.action_report_product_label_dymo", self._labels_data([450]))
+ self.assertEqual(calls, [450])
+
+ def test_labels_rendered_as_html_are_not_batched(self):
+ """In tests the PDF falls back to HTML: the labels come out in a single
+ render instead of trying to merge HTML as PDF."""
+ self.env["ir.config_parameter"].sudo().set_int("product_ux.dymo_label_batch_size", 2)
+ labels = self._labels_data([5])
+ labels["labels"][0].update(barcode_value="", barcode_text="")
+ labels["label_template"] = "product.barcode_dymo_label"
+ content, report_type = self.report._render_qweb_pdf("product.action_report_product_label_dymo", data=labels)
+ self.assertEqual(report_type, "html")
+ self.assertEqual(content.decode().count("Label 0"), 5)
+
+ # Pricelists
+
+ def test_surcharge_rules_round_to_price_precision(self):
+ """Surcharge rules round by default; discount rules do not, so they stay
+ plain discounts and sales keep showing the discount."""
+ rounding = 10 ** -self.env["decimal.precision"].precision_get("Product Price")
+ item = self.env["product.pricelist.item"].new({"pricelist_id": self.pricelist.id, "compute_price": "fixed"})
+
+ item.compute_price = "discount"
+ item._onchange_compute_price()
+ item.price_discount = 10.0
+ self.assertEqual(item.price_round, 0.0)
+ self.assertTrue(item.is_plain_discount, "sales show the discount of this rule")
+
+ item.compute_price = "markup"
+ item._onchange_compute_price()
+ self.assertAlmostEqual(item.price_round, rounding)
+
+ item.price_round = 1.0
+ item.compute_price = "discount"
+ item._onchange_compute_price()
+ item.compute_price = "markup"
+ item._onchange_compute_price()
+ self.assertEqual(item.price_round, 1.0, "a rounding set by hand is kept")
+
+ item.compute_price = "fixed"
+ item._onchange_compute_price()
+ self.assertEqual(item.price_round, 0.0)
+
+ def test_contextual_pricelist_by_name_list_or_id(self):
+ Template = self.env["product.template"]
+ for value in (self.pricelist.name, [self.pricelist.id], self.pricelist.id):
+ with self.subTest(pricelist=value):
+ self.assertEqual(Template.with_context(pricelist=value)._get_contextual_pricelist(), self.pricelist)
+ self.assertFalse(Template.with_context(pricelist="No such pricelist")._get_contextual_pricelist())
+ self.assertFalse(Template.with_context(pricelist=[])._get_contextual_pricelist())
+
+ def test_pricelist_price_follows_the_pricelist_in_context(self):
+ for pricelist in (self.pricelist.name, self.pricelist.id):
+ with self.subTest(pricelist=pricelist):
+ self.assertAlmostEqual(self.template.with_context(pricelist=pricelist).pricelist_price, 90.0)
+ self.assertAlmostEqual(self.product.with_context(pricelist=pricelist).pricelist_price, 90.0)
+
+ def test_pricelist_item_count(self):
+ self.assertEqual(self.pricelist.item_count, 1)
+ self.assertEqual(self.env["product.pricelist"].create({"name": "Empty"}).item_count, 0)
+
+ # Products and units
+
+ def test_search_product_by_vendor_product_code(self):
+ vendor = self.env["res.partner"].create({"name": "Product UX Vendor"})
+ self.env["product.supplierinfo"].create(
+ {"partner_id": vendor.id, "product_tmpl_id": self.template.id, "product_code": "UX-VND-001"}
+ )
+ found = self.env["product.template"].search([("sellers_product_code", "=", "UX-VND-001")])
+ self.assertEqual(found, self.template)
+
+ def test_archiving_a_product_is_tracked(self):
+ self.template.action_archive()
+ self.env.flush_all()
+ self.env.cr.precommit.run()
+ self.assertTrue(
+ self.template.message_ids.filtered(lambda message: message.message_type == "tracking"),
+ "archiving leaves a tracking message in the chatter",
+ )
+
+ def test_uom_default_search_view_searches_by_description(self):
+ arch = self.env["uom.uom"].get_views([(False, "search")])["views"]["search"]["arch"]
+ self.assertIn("description", arch)
+ self.assertIn('name="inactive"', arch, "the native Archived filter is kept")
diff --git a/product_ux/views/product_pricelist_item_view.xml b/product_ux/views/product_pricelist_item_view.xml
index 07c9bbb6b..df1c11ac5 100644
--- a/product_ux/views/product_pricelist_item_view.xml
+++ b/product_ux/views/product_pricelist_item_view.xml
@@ -5,13 +5,8 @@
product.pricelist.item
-
-
- True
-
-
+
-
@@ -19,8 +14,9 @@
product.pricelist.item.searchproduct.pricelist.item
+
-
+
@@ -29,13 +25,11 @@
-
-
-
-
-
-
-
+
+
+
+
+
diff --git a/product_ux/views/product_pricelist_view.xml b/product_ux/views/product_pricelist_view.xml
index da2aeca96..dc33180ee 100644
--- a/product_ux/views/product_pricelist_view.xml
+++ b/product_ux/views/product_pricelist_view.xml
@@ -5,11 +5,11 @@
product.pricelist
-
-