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. +

+
+
+ + +