diff --git a/product_variant_search/README.rst b/product_variant_search/README.rst new file mode 100644 index 000000000..db4f82ce1 --- /dev/null +++ b/product_variant_search/README.rst @@ -0,0 +1,110 @@ +====================== +Product Variant Search +====================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:b5047013150535a35b749b6d762e3d78493e59a95e1e6f8e482018041157fb67 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fproduct--variant-lightgray.png?logo=github + :target: https://github.com/OCA/product-variant/tree/18.0/product_variant_search + :alt: OCA/product-variant +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/product-variant-18-0/product-variant-18-0-product_variant_search + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/product-variant&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module improves product variant searching by matching the variant +display name (including attribute values) across all product selection +fields. + +It ensures attribute values shown in the variant name (e.g., “Red”, +“XL”, “128GB”) are consistently searchable, reducing friction when +selecting the correct variant. + +For flexibility in this module’s ``name_search``, it can be used +together with +```base_name_search_improved`` `__. + +**Table of contents** + +.. contents:: + :local: + +Installation +============ + +On installation, post_init_hook assigns search_name for up to 20,000 +products (hard cap) to avoid performance issues in huge DBs. Any +remaining products must be processed by the “Assign product search name” +cron job. This cron is disabled by default, so an administrator should +enable it when needed, then disable it again once all products have +search_name assigned. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Quartile + +Contributors +------------ + +- `Quartile `__: + + - Aung Ko Ko Lin + - Yoshi Tashiro + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-yostashiro| image:: https://github.com/yostashiro.png?size=40px + :target: https://github.com/yostashiro + :alt: yostashiro +.. |maintainer-aungkokolin1997| image:: https://github.com/aungkokolin1997.png?size=40px + :target: https://github.com/aungkokolin1997 + :alt: aungkokolin1997 + +Current `maintainers `__: + +|maintainer-yostashiro| |maintainer-aungkokolin1997| + +This module is part of the `OCA/product-variant `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/product_variant_search/__init__.py b/product_variant_search/__init__.py new file mode 100644 index 000000000..cc6b6354a --- /dev/null +++ b/product_variant_search/__init__.py @@ -0,0 +1,2 @@ +from . import models +from .hooks import post_init_hook diff --git a/product_variant_search/__manifest__.py b/product_variant_search/__manifest__.py new file mode 100644 index 000000000..0142560ac --- /dev/null +++ b/product_variant_search/__manifest__.py @@ -0,0 +1,16 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Product Variant Search", + "summary": "Search for product variants with display name", + "version": "18.0.1.0.0", + "category": "Product", + "license": "AGPL-3", + "website": "https://github.com/OCA/product-variant", + "author": "Quartile, Odoo Community Association (OCA)", + "depends": ["product"], + "data": ["data/ir_cron.xml"], + "post_init_hook": "post_init_hook", + "maintainers": ["yostashiro", "aungkokolin1997"], + "installable": True, +} diff --git a/product_variant_search/data/ir_cron.xml b/product_variant_search/data/ir_cron.xml new file mode 100644 index 000000000..ae994ad1e --- /dev/null +++ b/product_variant_search/data/ir_cron.xml @@ -0,0 +1,11 @@ + + + Assign product search name + + code + model._cron_populate_search_name() + 1 + weeks + False + + diff --git a/product_variant_search/hooks.py b/product_variant_search/hooks.py new file mode 100644 index 000000000..0abca91bd --- /dev/null +++ b/product_variant_search/hooks.py @@ -0,0 +1,22 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +BATCH_SIZE = 2000 +POST_INIT_CAP = 20000 # max products to process in hook + + +def post_init_hook(env): + Product = env["product.product"] + processed = 0 + last_id = 0 + while processed < POST_INIT_CAP: + recs = Product.search( + [("id", ">", last_id)], + order="id asc", + limit=min(BATCH_SIZE, POST_INIT_CAP - processed), + ) + if not recs: + break + recs.assign_search_name_all_langs() + last_id = recs[-1].id + processed += len(recs) diff --git a/product_variant_search/models/__init__.py b/product_variant_search/models/__init__.py new file mode 100644 index 000000000..4a2f9b303 --- /dev/null +++ b/product_variant_search/models/__init__.py @@ -0,0 +1,3 @@ +from . import product_attribute_value +from . import product_product +from . import product_template diff --git a/product_variant_search/models/product_attribute_value.py b/product_variant_search/models/product_attribute_value.py new file mode 100644 index 000000000..ee5f7dc29 --- /dev/null +++ b/product_variant_search/models/product_attribute_value.py @@ -0,0 +1,17 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class ProductAttributeValue(models.Model): + _inherit = "product.attribute.value" + + def write(self, vals): + res = super().write(vals) + if "name" in vals: + ptavs = self.env["product.template.attribute.value"].search( + [("product_attribute_value_id", "in", self.ids)] + ) + ptavs.ptav_product_variant_ids.assign_search_name_all_langs() + return res diff --git a/product_variant_search/models/product_product.py b/product_variant_search/models/product_product.py new file mode 100644 index 000000000..b2fa6afe4 --- /dev/null +++ b/product_variant_search/models/product_product.py @@ -0,0 +1,79 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json + +from odoo import api, fields, models +from odoo.osv import expression + + +class ProductProduct(models.Model): + _inherit = "product.product" + + search_name = fields.Char(readonly=True, translate=True, index=True) + + @api.model_create_multi + def create(self, vals_list): + variants = super().create(vals_list) + variants.assign_search_name_all_langs() + return variants + + def write(self, vals): + res = super().write(vals) + if "default_code" in vals: + self.assign_search_name_all_langs() + return res + + def assign_search_name_all_langs(self): + langs = self.env["res.lang"].search([("active", "=", True)]).mapped("code") + values = [] + for variant in self: + data = { + lang: variant.with_context(lang=lang).display_name for lang in langs + } + values.append((variant.id, json.dumps(data))) + self.env.cr.execute_values( + """ + UPDATE product_product AS p + SET search_name = v.search_name::jsonb + FROM (VALUES %s) AS v(id, search_name) + WHERE p.id = v.id + """, + values, + template="(%s, %s)", + ) + self.invalidate_recordset(["search_name"]) + return + + @api.model + def name_search(self, name="", args=None, operator="ilike", limit=100): + args = args or [] + if not name: + return super().name_search(name, args, operator, limit) + res = super().name_search(name, args, operator, limit) + if limit and len(res) >= limit: + return res + found_ids = [pid for pid, _ in res] + limit_rest = (limit - len(res)) if limit else None + extra_domain = expression.AND( + [ + args, + [("id", "not in", found_ids)], + [("search_name", operator, name)], + ] + ) + extra = self.search_fetch(extra_domain, ["display_name"], limit=limit_rest) + return res + [(p.id, p.display_name) for p in extra.sudo()] + + @api.model + def _cron_populate_search_name(self, batch_size=2000): + Product = self.env["product.product"] + domain = [("search_name", "=", False)] + recs = Product.search(domain, limit=batch_size) + if not recs: + return + recs.assign_search_name_all_langs() + if Product.search(domain, limit=1): + self.env.ref( + "product_variant_search.ir_cron_populate_search_name" + )._trigger() diff --git a/product_variant_search/models/product_template.py b/product_variant_search/models/product_template.py new file mode 100644 index 000000000..80a41e8e1 --- /dev/null +++ b/product_variant_search/models/product_template.py @@ -0,0 +1,14 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + def write(self, vals): + res = super().write(vals) + if "name" in vals: + self.product_variant_ids.assign_search_name_all_langs() + return res diff --git a/product_variant_search/pyproject.toml b/product_variant_search/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/product_variant_search/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/product_variant_search/readme/CONTRIBUTORS.md b/product_variant_search/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..2721fe7ec --- /dev/null +++ b/product_variant_search/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- [Quartile](https://www.quartile.co): + - Aung Ko Ko Lin + - Yoshi Tashiro diff --git a/product_variant_search/readme/DESCRIPTION.md b/product_variant_search/readme/DESCRIPTION.md new file mode 100644 index 000000000..cf1c9c544 --- /dev/null +++ b/product_variant_search/readme/DESCRIPTION.md @@ -0,0 +1,8 @@ +This module improves product variant searching by matching the variant display name +(including attribute values) across all product selection fields. + +It ensures attribute values shown in the variant name (e.g., “Red”, “XL”, “128GB”) are +consistently searchable, reducing friction when selecting the correct variant. + +For flexibility in this module’s `name_search`, it can be used together with +[`base_name_search_improved`](https://github.com/OCA/server-tools/tree/18.0/base_name_search_improved). diff --git a/product_variant_search/readme/INSTALL.md b/product_variant_search/readme/INSTALL.md new file mode 100644 index 000000000..e157f1661 --- /dev/null +++ b/product_variant_search/readme/INSTALL.md @@ -0,0 +1,4 @@ +On installation, post_init_hook assigns search_name for up to 20,000 products (hard cap) to avoid +performance issues in huge DBs. Any remaining products must be processed by the “Assign product search name” +cron job. This cron is disabled by default, so an administrator should enable it when needed, then disable it +again once all products have search_name assigned. diff --git a/product_variant_search/static/description/index.html b/product_variant_search/static/description/index.html new file mode 100644 index 000000000..5b53efed2 --- /dev/null +++ b/product_variant_search/static/description/index.html @@ -0,0 +1,447 @@ + + + + + +Product Variant Search + + + + + + diff --git a/product_variant_search/tests/__init__.py b/product_variant_search/tests/__init__.py new file mode 100644 index 000000000..023bfa57e --- /dev/null +++ b/product_variant_search/tests/__init__.py @@ -0,0 +1 @@ +from . import test_product_variant_search diff --git a/product_variant_search/tests/test_product_variant_search.py b/product_variant_search/tests/test_product_variant_search.py new file mode 100644 index 000000000..8bb166d5b --- /dev/null +++ b/product_variant_search/tests/test_product_variant_search.py @@ -0,0 +1,71 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo.fields import Command + +from odoo.addons.base.tests.common import BaseCommon + + +class TestProductVariantSearch(BaseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + ja = ( + cls.env["res.lang"] + .with_context(active_test=False) + .search([("code", "=", "ja_JP")]) + ) + cls.env["base.language.install"].create({"lang_ids": ja.ids}).lang_install() + # Attributes: Color (Red/Blue), Size (S/M) + Attr = cls.env["product.attribute"] + Val = cls.env["product.attribute.value"] + cls.attr_color = Attr.create({"name": "Color"}) + cls.val_red = Val.create({"name": "Red", "attribute_id": cls.attr_color.id}) + cls.val_blue = Val.create({"name": "Blue", "attribute_id": cls.attr_color.id}) + cls.attr_size = Attr.create({"name": "Size"}) + cls.val_s = Val.create({"name": "S", "attribute_id": cls.attr_size.id}) + cls.val_m = Val.create({"name": "M", "attribute_id": cls.attr_size.id}) + cls.val_red.with_context(lang="ja_JP").write({"name": "赤"}) + cls.val_blue.with_context(lang="ja_JP").write({"name": "青"}) + cls.val_s.with_context(lang="ja_JP").write({"name": "小"}) + cls.val_m.with_context(lang="ja_JP").write({"name": "中"}) + cls.tmpl = cls.env["product.template"].create( + { + "name": "Test Product", + "attribute_line_ids": [ + Command.create( + { + "attribute_id": cls.attr_color.id, + "value_ids": [ + Command.set([cls.val_red.id, cls.val_blue.id]), + ], + } + ), + Command.create( + { + "attribute_id": cls.attr_size.id, + "value_ids": [ + Command.set([cls.val_s.id, cls.val_m.id]), + ], + } + ), + ], + } + ) + + def test_name_search_variant_by_en(self): + Product = self.env["product.product"].with_context(lang="en_US") + self.assertEqual(len(Product.name_search("Test Product")), 4) + self.assertEqual(len(Product.name_search("Test Product (Red,")), 2) + self.assertEqual(len(Product.name_search("Test Product (Blue,")), 2) + self.assertEqual(len(Product.name_search("Test Product (Red, S)")), 1) + self.assertEqual(len(Product.name_search("Test Product (Blue, M)")), 1) + + def test_name_search_variant_by_jp(self): + Product = self.env["product.product"].with_context(lang="ja_JP") + self.assertEqual(len(Product.name_search("Test Product")), 4) + self.assertEqual(len(Product.name_search("Test Product (赤,")), 2) + self.assertEqual(len(Product.name_search("Test Product (青,")), 2) + self.assertEqual(len(Product.name_search("Test Product (赤, 小)")), 1) + self.assertEqual(len(Product.name_search("Test Product (青, 中)")), 1)