From b03b557d96f87e09a3b7c9456a40b47eff96f0b0 Mon Sep 17 00:00:00 2001 From: mav-adhoc Date: Mon, 24 Aug 2026 19:12:13 +0000 Subject: [PATCH] [ADD] search_ux: search products and partners by keywords and configurable fields Adds a "search keywords" field on products and partners for aliases and trade names, and a per model configuration to add fields and related sources to the search. It only hooks into the extension points Odoo documents, on the two search surfaces of the backend: name_search on product.template and product.product for the autocomplete (calling super and completing the suggestion list), _search_display_name on res.partner, and _get_view to add the search_extended seam to the free text search of the search views, composing with whatever filter_domain other modules left there. Only those models inherit the mixin, and the extra criteria are built as a single domain resolved in a single query, which runs only when the native search did not fill the limit. The extended search only applies to partial match operators, so an exact search keeps matching exactly, and a related source the user cannot read is skipped instead of raising. The configuration lives on its own model, restricted to the models that inherit the mixin, with a field picker that walks relations, and rejects on save what cannot be sustained: html, binary and non stored fields, wrong paths, group restricted fields and more than five fields per model. Customer specific sources are added from customer modules by inheriting _get_extra_search_domains, without forking this module. --- search_ux/README.rst | 134 ++++++++++++ search_ux/__init__.py | 1 + search_ux/__manifest__.py | 43 ++++ search_ux/models/__init__.py | 5 + search_ux/models/product_product.py | 35 ++++ search_ux/models/product_template.py | 34 +++ search_ux/models/res_partner.py | 27 +++ search_ux/models/search_ux_config.py | 222 ++++++++++++++++++++ search_ux/models/search_ux_mixin.py | 139 +++++++++++++ search_ux/security/ir.model.access.csv | 5 + search_ux/tests/__init__.py | 1 + search_ux/tests/test_search_ux.py | 228 +++++++++++++++++++++ search_ux/views/product_views.xml | 14 ++ search_ux/views/res_partner_views.xml | 13 ++ search_ux/views/search_ux_config_views.xml | 89 ++++++++ 15 files changed, 990 insertions(+) create mode 100644 search_ux/README.rst create mode 100644 search_ux/__init__.py create mode 100644 search_ux/__manifest__.py create mode 100644 search_ux/models/__init__.py create mode 100644 search_ux/models/product_product.py create mode 100644 search_ux/models/product_template.py create mode 100644 search_ux/models/res_partner.py create mode 100644 search_ux/models/search_ux_config.py create mode 100644 search_ux/models/search_ux_mixin.py create mode 100644 search_ux/security/ir.model.access.csv create mode 100644 search_ux/tests/__init__.py create mode 100644 search_ux/tests/test_search_ux.py create mode 100644 search_ux/views/product_views.xml create mode 100644 search_ux/views/res_partner_views.xml create mode 100644 search_ux/views/search_ux_config_views.xml diff --git a/search_ux/README.rst b/search_ux/README.rst new file mode 100644 index 00000000..74a94f4d --- /dev/null +++ b/search_ux/README.rst @@ -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 `_. +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. diff --git a/search_ux/__init__.py b/search_ux/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/search_ux/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/search_ux/__manifest__.py b/search_ux/__manifest__.py new file mode 100644 index 00000000..a01a66d9 --- /dev/null +++ b/search_ux/__manifest__.py @@ -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 . +# +############################################################################## +{ + "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, +} diff --git a/search_ux/models/__init__.py b/search_ux/models/__init__.py new file mode 100644 index 00000000..3736dada --- /dev/null +++ b/search_ux/models/__init__.py @@ -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 diff --git a/search_ux/models/product_product.py b/search_ux/models/product_product.py new file mode 100644 index 00000000..8b677299 --- /dev/null +++ b/search_ux/models/product_product.py @@ -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 diff --git a/search_ux/models/product_template.py b/search_ux/models/product_template.py new file mode 100644 index 00000000..da7b2e4f --- /dev/null +++ b/search_ux/models/product_template.py @@ -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 diff --git a/search_ux/models/res_partner.py b/search_ux/models/res_partner.py new file mode 100644 index 00000000..94dea64e --- /dev/null +++ b/search_ux/models/res_partner.py @@ -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 diff --git a/search_ux/models/search_ux_config.py b/search_ux/models/search_ux_config.py new file mode 100644 index 00000000..c809a754 --- /dev/null +++ b/search_ux/models/search_ux_config.py @@ -0,0 +1,222 @@ +############################################################################## +# For copyright and license notices, see __manifest__.py file in module root +############################################################################## +from odoo import api, fields, models +from odoo.exceptions import ValidationError + +from .search_ux_mixin import DEFAULT_MIN_CHARS + +DEFAULT_MAX_FIELDS = 5 +MAX_FIELDS_PARAM = "search_ux.max_fields" +SEARCHABLE_TYPES = ("char", "text") +RELATED_SOURCES = ("lot", "supplier_code", "packaging_barcode") + + +def _invalidate(env): + """The settings live in an ormcache and the seam is injected in the views.""" + env.registry.clear_cache() + env.registry.clear_cache("templates") + + +class SearchUxConfig(models.Model): + _name = "search.ux.config" + _description = "Extended Search Configuration" + _rec_name = "model_id" + + model_id = fields.Many2one( + "ir.model", + "Model", + required=True, + ondelete="cascade", + domain="[('model', 'in', available_models)]", + ) + model = fields.Char("Technical Name", related="model_id.model", store=True, index=True) + available_models = fields.Json(compute="_compute_available_models") + field_ids = fields.One2many( + "search.ux.field", + "config_id", + "Fields to Include", + help="Text fields added to the search when the native one does not " "fill the suggestion list.", + ) + min_chars = fields.Integer( + "Minimum Characters", + default=DEFAULT_MIN_CHARS, + required=True, + help="Number of characters from which the extended search is triggered.", + ) + search_lot = fields.Boolean("Search by Lot / Serial") + search_supplier_code = fields.Boolean("Search by Vendor Code") + search_packaging_barcode = fields.Boolean("Search by Packaging Barcode") + active = fields.Boolean(default=True) + + _model_uniq = models.Constraint( + "unique(model_id)", + "There is already an extended search configuration for this model.", + ) + + def _compute_available_models(self): + models_ = sorted( + name + for name, model in self.env.registry.items() + if hasattr(model, "_search_ux_default_paths") and not model._abstract + ) + self.available_models = models_ + + def _enabled_sources(self): + self.ensure_one() + return tuple(source for source in RELATED_SOURCES if self["search_%s" % source]) + + def _max_fields(self): + return int(self.env["ir.config_parameter"].sudo().get_param(MAX_FIELDS_PARAM, DEFAULT_MAX_FIELDS)) + + @api.constrains("model_id") + def _check_model(self): + for rec in self: + model = self.env.get(rec.model_id.model) + if model is None or not hasattr(model, "_search_ux_default_paths"): + raise ValidationError( + self.env._( + 'Model "%s" does not implement the extended search. It can ' + "only be configured on the models that inherit it explicitly " + "(product and contact).", + rec.model_id.model, + ) + ) + + @api.constrains("field_ids") + def _check_max_fields(self): + max_fields = self._max_fields() + for rec in self: + if len(rec.field_ids) > max_fields: + raise ValidationError( + self.env._( + "You configured %(count)s fields on %(model)s and the maximum " + "is %(max)s. More fields make the search slower: if you need " + 'several criteria, load them in "Search Keywords".', + count=len(rec.field_ids), + model=rec.model, + max=max_fields, + ) + ) + + @api.constrains("min_chars") + def _check_min_chars(self): + for rec in self.filtered(lambda x: x.min_chars < 1): + raise ValidationError(self.env._("The minimum number of characters must be at least 1.")) + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + _invalidate(self.env) + return records + + def write(self, vals): + res = super().write(vals) + _invalidate(self.env) + return res + + def unlink(self): + res = super().unlink() + _invalidate(self.env) + return res + + +class SearchUxField(models.Model): + _name = "search.ux.field" + _description = "Extended Search Field" + _rec_name = "path" + + config_id = fields.Many2one("search.ux.config", "Configuration", required=True, ondelete="cascade") + model = fields.Char(related="config_id.model", string="Model Name") + path = fields.Char( + "Field", + required=True, + help="Technical name of the field, or forward path " "(product_tmpl_id.my_field).", + ) + + @api.constrains("path", "config_id") + def _check_path(self): + for rec in self: + rec._validate_path() + # the limit per model is also checked when adding a single line + rec.config_id._check_max_fields() + + def _validate_path(self): + """Reject what cannot be searched in SQL or degrades the search.""" + self.ensure_one() + model = self.env.get(self.config_id.model_id.model) + if model is None: + return + parts = (self.path or "").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=self.path, + model=model._name, + ) + ) + if index < len(parts) - 1: + if not field.comodel_name: + raise ValidationError( + self.env._( + '"%s" is not a relational field, the path cannot be ' "followed.", + name, + ) + ) + model = self.env[field.comodel_name] + else: + self._check_searchable(field) + + def _check_searchable(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, or load the aliases in " + '"Search Keywords".', + field.string, + ) + ) + if field.type in ("binary", "image"): + raise ValidationError(self.env._('"%s" is an attachment, it cannot be searched.', field.string)) + if not field.store: + raise ValidationError( + self.env._( + '"%s" is not stored in the database, it cannot be searched in SQL.', + field.string, + ) + ) + if field.type not in SEARCHABLE_TYPES: + raise ValidationError( + self.env._( + '"%(name)s" is a %(type)s field. Only text fields can be searched.', + name=field.string, + type=field.type, + ) + ) + if field.groups: + raise ValidationError( + self.env._( + '"%s" is restricted by groups: users without access would get an ' "error when searching.", + field.string, + ) + ) + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + _invalidate(self.env) + return records + + def write(self, vals): + res = super().write(vals) + _invalidate(self.env) + return res + + def unlink(self): + res = super().unlink() + _invalidate(self.env) + return res diff --git a/search_ux/models/search_ux_mixin.py b/search_ux/models/search_ux_mixin.py new file mode 100644 index 00000000..ecbf9975 --- /dev/null +++ b/search_ux/models/search_ux_mixin.py @@ -0,0 +1,139 @@ +############################################################################## +# For copyright and license notices, see __manifest__.py file in module root +############################################################################## +from odoo import api, fields, models, tools +from odoo.fields import Domain + +DEFAULT_MIN_CHARS = 3 +# the extended search only makes sense on partial match operators +LIKE_OPERATORS = ("ilike", "like") + + +class SearchUxMixin(models.AbstractModel): + """Add configurable fields to the native search, without replacing it. + + It only runs when the native search did not fill the suggestion list, and + always as a single query. + """ + + _name = "search.ux.mixin" + _description = "Extended Search" + + # paths searched even without configuration (empty, they change nothing) + _search_ux_default_paths = () + + search_extended = fields.Char( + compute="_compute_search_extended", + search="_search_extended", + help="Technical field: exposes the extended search to the search views, " + "so the Search... box of the lists finds the same as the autocomplete.", + ) + + def _compute_search_extended(self): + self.search_extended = False + + @api.model + def _search_extended(self, operator, value): + """Search seam for the list search views. Neutral when it does not apply.""" + if operator in Domain.NEGATIVE_OPERATORS: + return Domain.TRUE + if operator not in LIKE_OPERATORS or not isinstance(value, str): + return Domain.FALSE + extra = self._get_extra_search_domains(value) + return Domain.OR(extra) if extra else Domain.FALSE + + @api.model + def _get_view(self, view_id=None, view_type="form", **options): + """Extend the free text search of the search views with the seam. + + It is done here and not in XML because other modules rewrite the + filter_domain of that field (partner_internal_code, for one) and the + last one to write it would win. + """ + arch, view = super()._get_view(view_id=view_id, view_type=view_type, **options) + if view_type != "search" or not self._search_ux_settings()[0]: + return arch, view + node = arch.find(".//field") + if node is None or not node.get("name"): + return arch, view + native = (node.get("filter_domain") or "").strip() + if not native: + native = "[('%s', 'ilike', self)]" % node.get("name") + if "search_extended" in native or not native.startswith("[") or not native.endswith("]"): + return arch, view + node.set( + "filter_domain", + "['|', %s, ('search_extended', 'ilike', self)]" % native[1:-1], + ) + return arch, view + + @api.model + @tools.ormcache("self._name") + def _search_ux_settings(self): + """(paths, min_chars, related sources) of the model, cached.""" + config = ( + self.env["search.ux.config"] + .sudo() + .with_context(active_test=False) + .search([("model", "=", self._name)], limit=1) + ) + if config and not config.active: + return ((), DEFAULT_MIN_CHARS, ()) + paths = tuple(self._search_ux_default_paths) + tuple(config.field_ids.mapped("path")) + return ( + paths, + config.min_chars if config else DEFAULT_MIN_CHARS, + config._enabled_sources() if config else (), + ) + + @api.model + def _get_extra_search_domains(self, term): + """Extra domains to search `term`. Extension point: inherit with super().""" + paths, min_chars, sources = self._search_ux_settings() + if not term or not isinstance(term, str) or len(term) < min_chars: + return [] + domains = [] + if paths: + # every word, in any order and on any field + domains.append( + Domain.AND([Domain.OR([Domain(path, "ilike", word) for path in paths]) for word in term.split()]) + ) + return domains + self._search_ux_related_domains(term, sources) + + @api.model + def _search_ux_related_domains(self, term, sources): + """Domains of the related sources enabled on the configuration.""" + return [] + + @api.model + def _search_ux_can_read(self, model_name): + """A source the user cannot read is skipped, never raised.""" + return model_name in self.env and self.env[model_name].has_access("read") + + @api.model + def _search_ux_extend(self, ids, term, domain, limit): + """Complete `ids` with the extra domains, in a single query.""" + if limit and len(ids) >= limit: + return ids + extra = self._get_extra_search_domains(term) + if not extra: + return ids + full_domain = Domain.AND( + [ + Domain(domain or Domain.TRUE), + Domain.OR(extra), + Domain("id", "not in", list(ids)), + ] + ) + return list(ids) + list(self._search(full_domain, limit=limit and limit - len(ids))) + + @api.model + def _search_ux_complete_name_search(self, results, term, domain, operator, limit): + """Add to the native name_search result whatever the extra domains bring.""" + if not term or operator not in LIKE_OPERATORS: + return results + ids = self._search_ux_extend([res[0] for res in results], term, domain, limit) + extra_ids = ids[len(results) :] + if not extra_ids: + return results + return results + [(record.id, record.display_name) for record in self.browse(extra_ids)] diff --git a/search_ux/security/ir.model.access.csv b/search_ux/security/ir.model.access.csv new file mode 100644 index 00000000..9b73ea3b --- /dev/null +++ b/search_ux/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_search_ux_config_user,search.ux.config user,model_search_ux_config,base.group_user,1,0,0,0 +access_search_ux_config_system,search.ux.config system,model_search_ux_config,base.group_system,1,1,1,1 +access_search_ux_field_user,search.ux.field user,model_search_ux_field,base.group_user,1,0,0,0 +access_search_ux_field_system,search.ux.field system,model_search_ux_field,base.group_system,1,1,1,1 diff --git a/search_ux/tests/__init__.py b/search_ux/tests/__init__.py new file mode 100644 index 00000000..2c2d311b --- /dev/null +++ b/search_ux/tests/__init__.py @@ -0,0 +1 @@ +from . import test_search_ux diff --git a/search_ux/tests/test_search_ux.py b/search_ux/tests/test_search_ux.py new file mode 100644 index 00000000..6683ddb8 --- /dev/null +++ b/search_ux/tests/test_search_ux.py @@ -0,0 +1,228 @@ +############################################################################## +# For copyright and license notices, see __manifest__.py file in module root +############################################################################## +from unittest.mock import patch + +from odoo.exceptions import ValidationError +from odoo.fields import Domain +from odoo.tests.common import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestSearchUx(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Product = cls.env["product.product"] + cls.Config = cls.env["search.ux.config"] + cls.product_model = cls.env.ref("product.model_product_product") + cls.partner_model = cls.env.ref("base.model_res_partner") + cls.template_model = cls.env.ref("product.model_product_template") + cls.product = cls.Product.create( + { + "name": "Ergonomic Chair", + "default_code": "CHA-01", + "search_keywords": "clappen alfa", + "description_sale": "beta armrest", + } + ) + cls.other = cls.Product.create({"name": "Fixed Chair", "default_code": "CHA-02"}) + cls.partner = cls.env["res.partner"].create({"name": "Southern Real Estate SA", "search_keywords": "remax"}) + + def _config(self, model_record, **vals): + return self.Config.create(dict(vals, model_id=model_record.id)) + + def _name_search_ids(self, model, term, **kwargs): + return [res[0] for res in self.env[model].name_search(term, **kwargs)] + + def test_keywords_product(self): + """The product keyword finds the variant without configuring anything.""" + self.assertIn(self.product.id, self._name_search_ids("product.product", "clappen")) + + def test_keywords_template(self): + """The template searches by keyword too (many2one to product.template).""" + found = self._name_search_ids("product.template", "clappen") + self.assertIn(self.product.product_tmpl_id.id, found) + + def test_keywords_partner(self): + """On contacts it is enough to add the field to the declarative domain.""" + self.assertIn(self.partner.id, self._name_search_ids("res.partner", "remax")) + + def test_native_result_without_configuration(self): + """AC5: without configuration nor keywords, the result is Odoo's.""" + found = set(self._name_search_ids("product.product", "Fixed Chair")) + native = self.Product.search([("name", "ilike", "Fixed Chair")]) + self.assertEqual(found, set(native.ids)) + + def test_skipped_when_native_fills_the_list(self): + """If the native search filled the limit, the extended one does not run.""" + with patch.object(type(self.Product), "_get_extra_search_domains", autospec=True) as spy: + self.Product.name_search("Chair", limit=1) + spy.assert_not_called() + + def test_multi_word_single_query(self): + """Every word, in any order and on any configured field, in one query.""" + config = self._config(self.product_model) + self.env["search.ux.field"].create({"config_id": config.id, "path": "product_tmpl_id.description_sale"}) + 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): + found = self.Product._search_ux_extend([], "beta clappen", None, 10) + self.assertEqual(len(calls), 1, "the extended search must be a single query") + self.assertIn(self.product.id, found) + self.assertNotIn(self.product.id, self._name_search_ids("product.product", "beta missing")) + + def test_minimum_characters(self): + """Below the configured minimum the extended search is not triggered.""" + self._config(self.product_model, min_chars=6) + self.assertNotIn(self.product.id, self._name_search_ids("product.product", "clapp")) + self.assertIn(self.product.id, self._name_search_ids("product.product", "clappen")) + + def test_turned_off(self): + """Archiving the configuration leaves the model with the bare native search.""" + config = self._config(self.product_model) + config.active = False + self.assertNotIn(self.product.id, self._name_search_ids("product.product", "clappen")) + + def test_honours_the_received_domain(self): + """The domain of the line wins, even if the keyword matches.""" + self.product.sale_ok = False + found = self._name_search_ids("product.product", "clappen", domain=[("sale_ok", "=", True)]) + self.assertNotIn(self.product.id, found) + + def test_extension_point(self): + """A customer module adds its source inheriting _get_extra_search_domains.""" + product_class = type(self.Product) + original = product_class._get_extra_search_domains + + def with_extra_source(self, term): + return original(self, term) + [Domain("default_code", "=", "CHA-02")] + + with patch.object(product_class, "_get_extra_search_domains", with_extra_source): + found = self._name_search_ids("product.product", "missing") + self.assertEqual(found, self.other.ids) + + def test_exact_operators_are_not_extended(self): + """An exact search must not match through the keywords.""" + self.assertFalse(self._name_search_ids("product.product", "clappen", operator="=")) + self.assertFalse(self._name_search_ids("product.product", "clappen", operator="=ilike")) + self.assertIn(self.product.id, self._name_search_ids("product.product", "clappen")) + exact_domain = self.env["res.partner"]._search_display_name("=", "remax") + self.assertNotIn("search_keywords", str(exact_domain)) + + def test_source_without_access_is_skipped(self): + """A related source the user cannot read is skipped, it does not raise.""" + if "stock.lot" not in self.env: + self.skipTest("stock is not installed") + self._config(self.product_model, search_lot=True) + user = self.env["res.users"].create( + { + "name": "No Inventory", + "login": "search_ux_no_inventory", + "group_ids": [(6, 0, [self.env.ref("base.group_user").id])], + } + ) + found = self.env["product.product"].with_user(user).name_search("clappen") + self.assertIn(self.product.id, [res[0] for res in found]) + + def test_list_search_finds_the_same_as_the_autocomplete(self): + """AC4: both surfaces return the same records for the same term.""" + term = "clappen" + autocomplete = set(self._name_search_ids("product.product", term)) + list_box = set( + self.Product.search( + [ + "|", + "|", + "|", + ("default_code", "ilike", term), + ("name", "ilike", term), + ("barcode", "ilike", term), + ("search_extended", "ilike", term), + ] + ).ids + ) + self.assertEqual(autocomplete, list_box) + self.assertIn(self.product.id, list_box) + + def test_list_search_on_partners(self): + """AC4 on contacts: their search box already goes through display_name.""" + found = self.env["res.partner"].search([("display_name", "ilike", "remax")]) + self.assertIn(self.partner.id, found.ids) + + def test_template_reuses_the_variant_related_sources(self): + """AC4: a lot found on the variant also finds the template in its list.""" + if "stock.lot" not in self.env: + self.skipTest("stock is not installed") + self._config(self.template_model, search_lot=True) + self.env["stock.lot"].create({"name": "SERIAL-XYZ", "product_id": self.product.id}) + found = self._name_search_ids("product.template", "SERIAL-XYZ") + self.assertIn(self.product.product_tmpl_id.id, found) + + def test_search_views_use_the_extended_seam(self): + """The free text search of the list views goes through the extended search.""" + views = [ + ("product.template", "product.product_template_search_view"), + ("product.product", "product.product_search_form_view"), + ("product.product", "product.product_view_search_catalog"), + ("res.partner", "base.view_res_partner_filter"), + ] + for model, xmlid in views: + with self.subTest(view=xmlid): + arch = self.env[model].get_view(self.env.ref(xmlid).id, "search")["arch"] + self.assertIn("search_extended", arch) + + def test_seam_composes_with_other_modules(self): + """The seam is added to whatever filter_domain the view already carried.""" + view = self.env.ref("base.view_res_partner_filter") + view.write( + { + "arch_db": view.arch_db.replace( + "[('display_name', 'ilike', self)]", + "[('complete_name', 'ilike', self)]", + ) + } + ) + arch = self.env["res.partner"].get_view(view.id, "search")["arch"] + self.assertIn("complete_name", arch) + self.assertIn("search_extended", arch) + + def test_no_seam_when_turned_off(self): + """With the configuration archived the search views are left untouched.""" + config = self._config(self.partner_model) + config.active = False + arch = self.env["res.partner"].get_view(self.env.ref("base.view_res_partner_filter").id, "search")["arch"] + self.assertNotIn("search_extended", arch) + + def test_rejects_fields_that_cannot_be_sustained(self): + """HTML, non stored, missing and non textual fields are rejected on save.""" + cases = [ + (self.template_model, "description"), + (self.partner_model, "contact_address"), + (self.partner_model, "field_that_does_not_exist"), + (self.partner_model, "active"), + (self.partner_model, "parent_id.missing"), + ] + for model_record, path in cases: + with self.subTest(path=path), self.assertRaises(ValidationError): + with self.env.cr.savepoint(): + config = self.Config.search([("model_id", "=", model_record.id)]) or self._config(model_record) + self.env["search.ux.field"].create({"config_id": config.id, "path": path}) + + def test_rejects_more_than_five_fields(self): + """The field limit per model is part of the contract, not a recommendation.""" + config = self._config(self.partner_model) + paths = ["ref", "vat", "website", "phone", "email", "city"] + with self.assertRaises(ValidationError): + self.env["search.ux.field"].create([{"config_id": config.id, "path": path} for path in paths]) + + def test_only_on_models_implementing_it(self): + """The extended search cannot be turned on for any model of the registry.""" + with self.assertRaises(ValidationError): + self._config(self.env.ref("base.model_res_users")) diff --git a/search_ux/views/product_views.xml b/search_ux/views/product_views.xml new file mode 100644 index 00000000..e0d7d11e --- /dev/null +++ b/search_ux/views/product_views.xml @@ -0,0 +1,14 @@ + + + + product.template.form.search.ux + product.template + + + + + + + + + diff --git a/search_ux/views/res_partner_views.xml b/search_ux/views/res_partner_views.xml new file mode 100644 index 00000000..2405c26a --- /dev/null +++ b/search_ux/views/res_partner_views.xml @@ -0,0 +1,13 @@ + + + + res.partner.form.search.ux + res.partner + + + + + + + + diff --git a/search_ux/views/search_ux_config_views.xml b/search_ux/views/search_ux_config_views.xml new file mode 100644 index 00000000..41111ace --- /dev/null +++ b/search_ux/views/search_ux_config_views.xml @@ -0,0 +1,89 @@ + + + + search.ux.config.form + search.ux.config + +
+ + +
+

+
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + search.ux.config.list + search.ux.config + + + + + + + + + + + Extended Search + search.ux.config + list,form + +

Configure the extended search of a model

+

+ Without configuration, the search behaves exactly like Odoo's, plus the + "Search Keywords" field of products and contacts. +

+
+
+ + +