Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions search_ux/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
.. |company| replace:: ADHOC SA

.. |company_logo| image:: https://raw.githubusercontent.com/ingadhoc/maintainer-tools/master/resources/adhoc-logo.png
:alt: ADHOC SA
:target: https://www.adhoc.com.ar

.. |icon| image:: https://raw.githubusercontent.com/ingadhoc/maintainer-tools/master/resources/adhoc-icon.png

.. image:: https://img.shields.io/badge/license-AGPL--3-blue.png
:target: https://www.gnu.org/licenses/agpl
:alt: License: AGPL-3

=========
Search UX
=========

Adds a place to store the aliases people actually search by, and a configuration
point to add fields to the search, without changing Odoo's native behaviour when
the search already works.

Out of the box:

* A "Search Keywords" field on products and contacts (internal: it is not
printed nor published), already included in the search of those two models.
* Nothing else enabled. Everything else is opt-in.

The extended search only runs when the native search did not fill the suggestion
list, and always as a single query.

Where it searches
=================

Both search surfaces of the backend find the same records:

* The **autocomplete** when picking the record on a document (sales order line,
purchase order line, Customer field): the native cascade runs first (internal
reference and barcode exact, reference and name partial, code between
brackets, vendor code) and the extended search only completes the suggestion
list if it was not filled.
* The **Search...** box of the list and kanban views. On products the search
views are extended with the technical field ``search_extended``, which
resolves the same criteria; on contacts nothing has to be extended, their
search box already goes through ``display_name``.

Installation
============

Only install the module.

Configuration
=============

Settings > Extended Search, per model:

* **Fields to Include**: model fields, Studio fields and forward paths
(``product_tmpl_id.my_field``).
* **Related Sources** (products): lots/serials, vendor code, packaging barcodes.
* **Minimum Characters** before the extended search is triggered (default 3).
* Archive the configuration to turn it off completely.

It rejects, on save: HTML fields, binary/attachment fields, non stored fields,
wrong paths, group restricted fields and more than 5 fields per model
(``search_ux.max_fields``).

Usage
=====

Load the aliases in "Search Keywords" and search by them from any many2one
(sales order lines, invoices, etc) or from the Search... box of the Products
and Contacts lists.

Rollout note
============

What to tell the customer before installing:

* The day it is installed **the field is empty and nothing new is found**. The
aliases are loaded by the customer: the module does not guess them and does
not migrate what is today in internal notes or tags.
* To load many at once, export Products or Contacts to a spreadsheet, fill the
"Search Keywords" column and import it back. One record per row, the aliases
separated by spaces.
* Accents and case follow whatever ``ilike`` does on the deployment: with the
``unaccent`` option disabled, "clapen" does not find "Clappen". It is a
deployment setting, not something the module decides.

Customization
=============

To search by something that is not a field of the model (an own model, a
history, business logic), inherit the single extension point from a customer
module::

class ProductProduct(models.Model):
_inherit = "product.product"

def _get_extra_search_domains(self, term):
domains = super()._get_extra_search_domains(term)
domains.append(Domain("id", "in",
self.env["my.model"]._search([("code", "ilike", term)])
.subselect("product_id")))
return domains

Known issues / Roadmap
======================

* It does not fix typos, does not reserve the lot when the product is found by
serial number and does not search inside HTML descriptions. All three are
explicit decisions.
* The configuration is per model: what is configured on ``product.template``
does not apply to ``product.product`` and the other way around. Sales order
lines search variants, the Products list searches templates, so a customer
that configures extra fields usually wants both. The keywords field, which is
what works out of the box, is consistent on both.
* Related sources (lots, vendor code, packaging barcodes) are skipped for users
without read access to those models, instead of raising: a salesperson
without inventory rights simply does not search by lot.

Bug Tracker
===========

Bugs are tracked on `GitHub Issues <https://github.com/ingadhoc/miscellaneous/issues>`_.
In case of trouble, please check there if your issue has already been reported.

Credits
=======

|company_logo|

|company|

This module is maintained by the |company|.

To contribute to this module, please visit https://github.com/ingadhoc.
1 change: 1 addition & 0 deletions search_ux/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
43 changes: 43 additions & 0 deletions search_ux/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
##############################################################################
#
# Copyright (C) 2026 ADHOC SA (http://www.adhoc.com.ar)
# All Rights Reserved.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
{
"name": "Search UX",
"version": "19.0.1.0.0",
"category": "Tools",
"sequence": 14,
"summary": "Search keywords and configurable fields for products and partners",
"author": "ADHOC SA",
"website": "www.adhoc.com.ar",
"license": "AGPL-3",
"images": [],
"depends": [
"product",
],
"data": [
"security/ir.model.access.csv",
"views/search_ux_config_views.xml",
"views/product_views.xml",
"views/res_partner_views.xml",
],
"demo": [],
"installable": True,
"auto_install": False,
"application": False,
}
5 changes: 5 additions & 0 deletions search_ux/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from . import search_ux_mixin
from . import search_ux_config
from . import product_template
from . import product_product
from . import res_partner
35 changes: 35 additions & 0 deletions search_ux/models/product_product.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
##############################################################################
# For copyright and license notices, see __manifest__.py file in module root
##############################################################################
from odoo import api, models
from odoo.fields import Domain


class ProductProduct(models.Model):
_name = "product.product"
_inherit = ["product.product", "search.ux.mixin"]

# the keywords live on the template
_search_ux_default_paths = ("product_tmpl_id.search_keywords",)

@api.model
def name_search(self, name="", domain=None, operator="ilike", limit=100):
results = super().name_search(name, domain, operator, limit)
return self._search_ux_complete_name_search(results, name, domain, operator, limit)

@api.model
def _search_ux_related_domains(self, term, sources):
domains = super()._search_ux_related_domains(term, sources)
if "supplier_code" in sources and self._search_ux_can_read("product.supplierinfo"):
sellers = self.env["product.supplierinfo"]._search([("product_code", "ilike", term)])
domains.append(
Domain("id", "in", sellers.subselect("product_id"))
| Domain("product_tmpl_id", "in", sellers.subselect("product_tmpl_id"))
)
if "packaging_barcode" in sources and self._search_ux_can_read("product.uom"):
packagings = self.env["product.uom"]._search([("barcode", "ilike", term)])
domains.append(Domain("id", "in", packagings.subselect("product_id")))
if "lot" in sources and self._search_ux_can_read("stock.lot"):
lots = self.env["stock.lot"]._search([("name", "ilike", term)])
domains.append(Domain("id", "in", lots.subselect("product_id")))
return domains
34 changes: 34 additions & 0 deletions search_ux/models/product_template.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
##############################################################################
# For copyright and license notices, see __manifest__.py file in module root
##############################################################################
from odoo import api, fields, models
from odoo.fields import Domain

KEYWORDS_HELP = (
"Aliases, synonyms, nicknames or trade names people use to look for this "
"record. Internal use: it is neither printed nor published."
)


class ProductTemplate(models.Model):
_name = "product.template"
_inherit = ["product.template", "search.ux.mixin"]

_search_ux_default_paths = ("search_keywords",)

# the label "Search Keywords" is derived from the field name
search_keywords = fields.Char(index="trigram", help=KEYWORDS_HELP)

@api.model
def name_search(self, name="", domain=None, operator="ilike", limit=100):
results = super().name_search(name, domain, operator, limit)
return self._search_ux_complete_name_search(results, name, domain, operator, limit)

@api.model
def _search_ux_related_domains(self, term, sources):
"""The related sources live on the variant: the template reuses them."""
domains = super()._search_ux_related_domains(term, sources)
variant_domains = self.env["product.product"]._search_ux_related_domains(term, sources)
if variant_domains:
domains.append(Domain("product_variant_ids", "any", Domain.OR(variant_domains)))
return domains
27 changes: 27 additions & 0 deletions search_ux/models/res_partner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
##############################################################################
# For copyright and license notices, see __manifest__.py file in module root
##############################################################################
from odoo import api, fields, models
from odoo.fields import Domain

from .product_template import KEYWORDS_HELP
from .search_ux_mixin import LIKE_OPERATORS


class ResPartner(models.Model):
_name = "res.partner"
_inherit = ["res.partner", "search.ux.mixin"]

_search_ux_default_paths = ("search_keywords",)

# the label "Search Keywords" is derived from the field name
search_keywords = fields.Char(index="trigram", help=KEYWORDS_HELP)

@api.model
def _search_display_name(self, operator, value):
"""The contact already searches declaratively: we add fields to that domain."""
domain = super()._search_display_name(operator, value)
if operator not in LIKE_OPERATORS or not isinstance(value, str):
return domain
extra = self._get_extra_search_domains(value)
return Domain.OR([domain] + extra) if extra else domain
Loading