diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 000000000..df70ac64c --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,6 @@ +odoo-addon-attribute_set @ git+https://github.com/OCA/odoo-pim.git@refs/pull/226/head#subdirectory=attribute_set +odoo-addon-attribute_set_jsonb @ git+https://github.com/OCA/odoo-pim.git@refs/pull/236/head#subdirectory=attribute_set_jsonb +odoo-addon-base_sparse_field_jsonb @ git+https://github.com/OCA/server-tools@refs/pull/3480/head#subdirectory=base_sparse_field_jsonb +odoo-addon-attribute_set_jsonb_index @ git+https://github.com/OCA/odoo-pim@refs/pull/237/head#subdirectory=attribute_set_jsonb_index +odoo-addon-website_attribute_set @ git+https://github.com/OCA/odoo-pim@refs/pull/229/head#subdirectory=website_attribute_set +odoo-addon-product_attribute_set @ git+https://github.com/OCA/odoo-pim.git@refs/pull/232/head#subdirectory=product_attribute_set diff --git a/website_attribute_set_jsonb/README.rst b/website_attribute_set_jsonb/README.rst new file mode 100644 index 000000000..6e83bf07c --- /dev/null +++ b/website_attribute_set_jsonb/README.rst @@ -0,0 +1,246 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +=========================== +Website Attribute Set JSONB +=========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:af4197ed08299d8d7f1d64646b2a8ce76b676bfc1540c77fe350543bf6e5d9ce + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-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%2Fodoo--pim-lightgray.png?logo=github + :target: https://github.com/OCA/odoo-pim/tree/19.0/website_attribute_set_jsonb + :alt: OCA/odoo-pim +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/odoo-pim-19-0/odoo-pim-19-0-website_attribute_set_jsonb + :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/odoo-pim&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module integrates OCA's ``attribute_set`` JSONB serialized +attributes with Odoo's website shop filtering system. + +Features +-------- + +- **Website Visibility Configuration**: Mark JSONB attributes as visible + in website shop filters +- **Multiple Filter Types**: Support for checkbox, dropdown, and range + slider filters +- **Facet Counting**: Displays count of products matching each filter + value +- **Range Queries**: Efficient range filtering for numeric/date + attributes using B-tree indexes +- **URL Parameter Preservation**: Filter selections persist across + pagination and sorting +- **Active Filter Tags**: Visual display of currently applied filters + with easy removal + +Use Cases +--------- + +- Heavy equipment dealers: Filter machines by capacity, year, brand +- E-commerce sites: Filter products by custom dynamic attributes +- B2B portals: Filter by technical specifications stored in JSONB + +Performance +----------- + +This module uses PostgreSQL JSONB operators directly for efficient +filtering: + +- GIN indexes for equality/containment queries +- B-tree expression indexes for range queries (>, <, BETWEEN) +- Direct SQL for facet counting to avoid ORM overhead + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Prerequisites +------------- + +1. Install ``attribute_set`` from OCA/odoo-pim +2. Install ``base_sparse_field_jsonb`` for JSONB support +3. Create serialized attributes on ``product.template`` + +Index Configuration +------------------- + +For optimal performance, configure indexes based on filter type: + +Equality Filters (checkbox, dropdown) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Set **Index Type** to **GIN (equality/containment)** on the attribute. + +Range Filters +~~~~~~~~~~~~~ + +Set **Index Type** to **B-tree (range queries)** on the attribute. + +Template Customization +---------------------- + +The filter template can be customized by inheriting: + +- ``website_attribute_set_jsonb.jsonb_attributes_filter`` +- ``website_attribute_set_jsonb.products_jsonb_attributes`` + +Example: + +.. code:: xml + + + +Styling +------- + +Override SCSS variables or add custom styles: + +.. code:: scss + + .o_jsonb_attribute_filter { + .accordion-body { + max-height: 400px; // Increase max height + } + } + +Usage +===== + +Configuration +------------- + +1. Navigate to **Settings > Technical > Attributes > Attributes** +2. Select a serialized attribute you want to show in website filters +3. Enable **Show in Website Filters** +4. Choose the **Filter Display Type**: + + - **Checkbox**: Multiple selection with checkboxes + - **Dropdown**: Single selection dropdown + - **Range Slider**: For numeric/date attributes with min/max input + +5. Set the **Website Filter Sequence** to control display order + +Filter Types +------------ + +Checkbox Filter +~~~~~~~~~~~~~~~ + +Best for attributes with multiple discrete values where users may want +to select multiple options: + +- Brand: Caterpillar, Komatsu, Volvo +- Color: Red, Blue, Green + +Dropdown Filter +~~~~~~~~~~~~~~~ + +Best for single-selection attributes or when there are many values: + +- Country of Origin +- Condition (New, Used, Refurbished) + +Range Filter +~~~~~~~~~~~~ + +Best for numeric attributes where users want to filter by range: + +- Capacity (kg): 1000 - 5000 +- Manufacturing Year: 2020 - 2024 +- Operating Weight: 10 - 50 tons + +**Note**: Range filters work best when the attribute has a B-tree index +configured in ``base_sparse_field_jsonb``. + +URL Parameters +-------------- + +Filter selections are passed as URL parameters: + +- Equality: ``?jsonb_x_brand=caterpillar,komatsu`` +- Range: ``?jsonb_range_x_capacity=1000-5000`` + +This allows for: + +- Bookmarkable filtered URLs +- SEO-friendly filter pages +- Integration with external marketing tools + +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 +------- + +* OBS Solutions B.V. + +Contributors +------------ + +- OBS Solutions B.V. https://www.obs-solutions.com + +Other credits +------------- + +Development +~~~~~~~~~~~ + +This module was developed by OBS Solutions B.V. + +Sponsors +~~~~~~~~ + +- International Heavy Equipment Dealer (Netherlands) + +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. + +This module is part of the `OCA/odoo-pim `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/website_attribute_set_jsonb/__init__.py b/website_attribute_set_jsonb/__init__.py new file mode 100644 index 000000000..91c5580fe --- /dev/null +++ b/website_attribute_set_jsonb/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/website_attribute_set_jsonb/__manifest__.py b/website_attribute_set_jsonb/__manifest__.py new file mode 100644 index 000000000..98591d2bd --- /dev/null +++ b/website_attribute_set_jsonb/__manifest__.py @@ -0,0 +1,26 @@ +{ + "name": "Website Attribute Set JSONB", + "version": "19.0.1.0.1", + "category": "Website/Website", + "summary": "Website shop filtering for JSONB serialized attributes", + "author": "OBS Solutions B.V., Odoo Community Association (OCA)", + "website": "https://github.com/OCA/odoo-pim", + "license": "AGPL-3", + "depends": [ + "website_sale", + "website_attribute_set", + "attribute_set_jsonb", + "attribute_set_jsonb_index", + ], + "data": [ + "views/attribute_attribute_views.xml", + "views/templates.xml", + ], + "assets": { + "web.assets_frontend": [ + "website_attribute_set_jsonb/static/src/scss/website_attribute_set.scss", + ], + }, + "installable": True, + "auto_install": False, +} diff --git a/website_attribute_set_jsonb/controllers/__init__.py b/website_attribute_set_jsonb/controllers/__init__.py new file mode 100644 index 000000000..12a7e529b --- /dev/null +++ b/website_attribute_set_jsonb/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/website_attribute_set_jsonb/controllers/main.py b/website_attribute_set_jsonb/controllers/main.py new file mode 100644 index 000000000..7a42a64a0 --- /dev/null +++ b/website_attribute_set_jsonb/controllers/main.py @@ -0,0 +1,224 @@ +"""Website Sale controller extension for JSONB attribute filtering.""" + +import logging + +from odoo import http +from odoo.http import request +from odoo.osv import expression + +from odoo.addons.website_sale.controllers.main import WebsiteSale + +_logger = logging.getLogger(__name__) + + +class WebsiteSaleJsonb(WebsiteSale): + def _shop_get_query_url_kwargs( + self, search_or_category, search=None, min_price=None, max_price=None, **kwargs + ): + """Override to fix website_attribute_set bug passing extra category arg. + + The OCA website_attribute_set module incorrectly calls this method with + category as the first argument. This override handles both signatures: + - Base Odoo: _shop_get_query_url_kwargs(search, min_price, max_price, ...) + - OCA: _shop_get_query_url_kwargs(category, search, min_price, max_price, ...) + """ + # Detect if called with the buggy OCA signature (5 positional args) + if search is not None: + # Called with (category, search, min_price, max_price, **kwargs) + # Ignore category and use correct params + actual_search = search + actual_min_price = min_price or 0.0 + actual_max_price = max_price or 0.0 + else: + # Called with standard signature (search, min_price, max_price, **kwargs) + actual_search = search_or_category + actual_min_price = kwargs.pop("min_price", 0.0) + actual_max_price = kwargs.pop("max_price", 0.0) + + return super()._shop_get_query_url_kwargs( + actual_search, actual_min_price, actual_max_price, **kwargs + ) + + def _get_search_domain( + self, search, category, attrib_values, search_in_description=True + ): + """Extend search domain to include JSONB attribute filters. + + This method is called by /shop route to build the product search domain. + We extend it to include JSONB attribute filters from URL parameters. + """ + domain = super()._get_search_domain( + search, category, attrib_values, search_in_description + ) + + # Get JSONB attribute filters from request + jsonb_filters = self._parse_jsonb_attribute_params() + if jsonb_filters: + jsonb_domain = request.env["product.template"]._get_jsonb_attribute_domain( + jsonb_filters + ) + if jsonb_domain: + domain = expression.AND([domain, jsonb_domain]) + + return domain + + def _parse_jsonb_attribute_params(self): + """Parse JSONB attribute filter parameters from request. + + URL format: + Equality filters: ?jsonb_x_color=red,blue&jsonb_x_brand=caterpillar + Range filters: ?jsonb_range_x_capacity=1000-5000 + + Returns: + dict: {attribute_name: [values]} or {attribute_name: {'min': x, 'max': y}} + """ + filters = {} + + for key, value in request.httprequest.args.items(): + if not value: + continue + + if key.startswith("jsonb_range_"): + # Range filter + attr_name = key[12:] # Remove 'jsonb_range_' prefix + if "-" in value: + parts = value.split("-", 1) + try: + min_val = float(parts[0]) if parts[0] else None + max_val = float(parts[1]) if parts[1] else None + filters[attr_name] = {"min": min_val, "max": max_val} + except ValueError: + _logger.warning( + "Invalid range value for %s: %s", attr_name, value + ) + continue + + elif key.startswith("jsonb_"): + # Equality filter + attr_name = key[6:] # Remove 'jsonb_' prefix + values = [v.strip() for v in value.split(",") if v.strip()] + if values: + filters[attr_name] = values + + return filters + + def _get_jsonb_attribute_values(self): + """Get current JSONB attribute filter values from request. + + Returns: + dict: {attribute_name: [selected_values]} for template rendering + """ + return self._parse_jsonb_attribute_params() + + @http.route() + def shop( + self, + page=0, + category=None, + search="", + min_price=0.0, + max_price=0.0, + ppg=False, + **post, + ): + """Extend shop route to include JSONB attributes in context.""" + response = super().shop( + page=page, + category=category, + search=search, + min_price=min_price, + max_price=max_price, + ppg=ppg, + **post, + ) + + # Add JSONB attribute data to the response values + if hasattr(response, "qcontext"): + # Get website-visible JSONB attributes + jsonb_attributes = request.env[ + "product.template" + ].get_website_jsonb_attributes() + + # Get current filter selections + selected_jsonb_values = self._get_jsonb_attribute_values() + + # Prepare attribute data with distinct values and facet counts + jsonb_attr_data = [] + for attr in jsonb_attributes: + attr_info = { + "attribute": attr, + "values": [], + "selected": selected_jsonb_values.get(attr.name, []), + "filter_type": attr.website_filter_type, + } + + if attr.website_filter_type == "range": + # Get min/max for range slider + min_val, max_val = attr._get_min_max_values() + attr_info["min_value"] = min_val + attr_info["max_value"] = max_val + + # Get current range selection + range_selection = selected_jsonb_values.get(attr.name) + if isinstance(range_selection, dict): + attr_info["selected_min"] = range_selection.get("min") + attr_info["selected_max"] = range_selection.get("max") + else: + # Get distinct values with counts for checkbox/select + distinct_values = attr._get_distinct_values() + facet_counts = attr._get_facet_counts() + + attr_info["values"] = [ + { + "value": val, + "count": facet_counts.get(val, 0), + "selected": val in attr_info["selected"], + } + for val in distinct_values + ] + + jsonb_attr_data.append(attr_info) + + response.qcontext["jsonb_attributes"] = jsonb_attr_data + response.qcontext["selected_jsonb_values"] = selected_jsonb_values + + return response + + def _get_search_options( + self, + category=None, + attrib_values=None, + pricelist=None, + min_price=0.0, + max_price=0.0, + conversion_rate=1, + **post, + ): + """Extend search options to preserve JSONB filter parameters.""" + options = super()._get_search_options( + category=category, + attrib_values=attrib_values, + pricelist=pricelist, + min_price=min_price, + max_price=max_price, + conversion_rate=conversion_rate, + **post, + ) + + # Add JSONB filter params to preserve them in pagination/sorting + jsonb_filters = self._parse_jsonb_attribute_params() + if jsonb_filters: + jsonb_params = {} + for attr_name, filter_value in jsonb_filters.items(): + if isinstance(filter_value, dict): + # Range filter + min_val = filter_value.get("min", "") + max_val = filter_value.get("max", "") + jsonb_params[f"jsonb_range_{attr_name}"] = f"{min_val}-{max_val}" + else: + # Equality filter + jsonb_params[f"jsonb_{attr_name}"] = ",".join(filter_value) + + options["jsonb_filters"] = jsonb_params + + return options diff --git a/website_attribute_set_jsonb/models/__init__.py b/website_attribute_set_jsonb/models/__init__.py new file mode 100644 index 000000000..cfac04e54 --- /dev/null +++ b/website_attribute_set_jsonb/models/__init__.py @@ -0,0 +1,2 @@ +from . import attribute_attribute +from . import product_template diff --git a/website_attribute_set_jsonb/models/attribute_attribute.py b/website_attribute_set_jsonb/models/attribute_attribute.py new file mode 100644 index 000000000..da9a491f5 --- /dev/null +++ b/website_attribute_set_jsonb/models/attribute_attribute.py @@ -0,0 +1,188 @@ +"""Extension to attribute.attribute for website filtering.""" + +from odoo import api, fields, models + + +class AttributeAttribute(models.Model): + """Extend attribute.attribute with website filtering configuration.""" + + _inherit = "attribute.attribute" + + website_visible = fields.Boolean( + string="Show in Website Filters", + default=False, + help="Display this attribute in website shop filters. " + "Only applicable to serialized (JSONB) attributes.", + ) + website_filter_type = fields.Selection( + selection=[ + ("checkbox", "Checkbox"), + ("select", "Dropdown"), + ("range", "Range Slider"), + ], + string="Filter Display Type", + default="checkbox", + help="How to display this attribute in website filters. " + "Range slider only works with numeric attributes that have B-tree indexes.", + ) + website_sequence = fields.Integer( + string="Website Filter Sequence", + default=10, + help="Order in which this attribute appears in website filters.", + ) + + @api.onchange("website_filter_type") + def _onchange_website_filter_type(self): + """Validate range filter type is only used with numeric attributes.""" + if self.website_filter_type == "range": + if self.attribute_type not in ("integer", "float", "date", "datetime"): + return { + "warning": { + "title": "Invalid Filter Type", + "message": "Range slider filter only works with numeric " + "(integer, float) or date (date, datetime) attributes. " + "Consider using checkbox or dropdown instead.", + } + } + if self.index_type != "btree": + return { + "warning": { + "title": "Performance Warning", + "message": "Range filters work best with B-tree indexes. " + "Consider setting Index Type to 'B-tree (range queries)' " + "for better performance.", + } + } + return None + + def _get_distinct_values(self, domain=None): + """Get distinct values for this serialized attribute. + + Args: + domain: Optional domain to filter products + + Returns: + list: Distinct values found in the database + """ + self.ensure_one() + + if not self.serialized or not self.serialization_field_id: + return [] + + table_name = self.model.replace(".", "_") + jsonb_column = self.serialization_field_id.name + + # Build WHERE clause from domain if provided + where_clause = f"({jsonb_column})::jsonb ? %s" + params = [self.name] + + # Add active filter for product.template + if self.model == "product.template": + where_clause += " AND active = true AND sale_ok = true" + + # Table/column names from trusted model fields, values are parameterized + query = f""" + SELECT DISTINCT ({jsonb_column})::jsonb->>%s as value + FROM {table_name} + WHERE {where_clause} + AND ({jsonb_column})::jsonb->>%s IS NOT NULL + ORDER BY value + """ # nosec B608 + params.extend([self.name, self.name]) + + self.env.cr.execute(query, params) + return [row[0] for row in self.env.cr.fetchall() if row[0]] + + def _get_min_max_values(self, domain=None): + """Get min and max values for numeric/date attributes. + + Args: + domain: Optional domain to filter products + + Returns: + tuple: (min_value, max_value) or (None, None) if not applicable + """ + self.ensure_one() + + if not self.serialized or not self.serialization_field_id: + return None, None + + if self.attribute_type not in ("integer", "float", "date", "datetime"): + return None, None + + table_name = self.model.replace(".", "_") + jsonb_column = self.serialization_field_id.name + + # Determine cast type + cast_map = { + "integer": "integer", + "float": "numeric", + "date": "date", + "datetime": "timestamp", + } + cast_type = cast_map.get(self.attribute_type, "text") + + # Build WHERE clause + where_clause = f"({jsonb_column})::jsonb ? %s" + params = [self.name] + + # Add active filter for product.template + if self.model == "product.template": + where_clause += " AND active = true AND sale_ok = true" + + # Table/column/cast from trusted model fields, values are parameterized + query = f""" + SELECT + MIN((({jsonb_column})::jsonb->>%s)::{cast_type}) as min_val, + MAX((({jsonb_column})::jsonb->>%s)::{cast_type}) as max_val + FROM {table_name} + WHERE {where_clause} + """ # nosec B608 + params.extend([self.name, self.name]) + + self.env.cr.execute(query, params) + row = self.env.cr.fetchone() + if row: + return row[0], row[1] + return None, None + + def _get_facet_counts(self, base_domain=None): + """Get value counts for this attribute within a filtered product set. + + Args: + base_domain: Domain to filter products + + Returns: + dict: {value: count} mapping + """ + self.ensure_one() + + if not self.serialized or not self.serialization_field_id: + return {} + + table_name = self.model.replace(".", "_") + jsonb_column = self.serialization_field_id.name + + # Build WHERE clause + where_clause = f"({jsonb_column})::jsonb ? %s" + params = [self.name] + + # Add active filter for product.template + if self.model == "product.template": + where_clause += " AND active = true AND sale_ok = true" + + # Table/column names from trusted model fields, values are parameterized + query = f""" + SELECT + ({jsonb_column})::jsonb->>%s as value, + COUNT(*) as count + FROM {table_name} + WHERE {where_clause} + AND ({jsonb_column})::jsonb->>%s IS NOT NULL + GROUP BY ({jsonb_column})::jsonb->>%s + ORDER BY count DESC + """ # nosec B608 + params.extend([self.name, self.name, self.name]) + + self.env.cr.execute(query, params) + return {row[0]: row[1] for row in self.env.cr.fetchall() if row[0]} diff --git a/website_attribute_set_jsonb/models/product_template.py b/website_attribute_set_jsonb/models/product_template.py new file mode 100644 index 000000000..8e8ac398e --- /dev/null +++ b/website_attribute_set_jsonb/models/product_template.py @@ -0,0 +1,138 @@ +"""Extension to product.template for JSONB attribute filtering.""" + +import logging + +from odoo import api, models + +_logger = logging.getLogger(__name__) + + +class ProductTemplate(models.Model): + """Extend product.template with JSONB attribute filtering methods.""" + + _inherit = "product.template" + + @api.model + def _get_jsonb_attribute_domain(self, attribute_filters): + """Build domain for JSONB attribute filtering. + + This method translates JSONB attribute filter parameters into + SQL-compatible domain expressions that can be used in searches. + + Args: + attribute_filters: dict of {attribute_name: [values]} for equality + or {attribute_name: {'min': x, 'max': y}} for range + + Returns: + list: Domain expression compatible with Odoo ORM + """ + if not attribute_filters: + return [] + + # Get the serialization field name for product.template + # This is typically 'x_custom_json_attrs' from attribute_set + serialization_field = self.env["ir.model.fields"].search( + [ + ("model", "=", "product.template"), + ("ttype", "=", "serialized"), + ], + limit=1, + ) + + if not serialization_field: + _logger.warning("No serialized field found on product.template") + return [] + + jsonb_column = serialization_field.name + + # Build list of product IDs that match all filters + # This is more efficient than building complex domains + matching_ids = self._get_jsonb_filtered_product_ids( + attribute_filters, jsonb_column + ) + + if matching_ids is None: + # No filters applied + return [] + elif not matching_ids: + # Filters applied but no matches + return [("id", "=", 0)] # Impossible domain + else: + return [("id", "in", matching_ids)] + + @api.model + def _get_jsonb_filtered_product_ids(self, attribute_filters, jsonb_column): + """Get product IDs matching JSONB attribute filters. + + Uses PostgreSQL JSONB operators for efficient filtering. + + Args: + attribute_filters: Filter dict + jsonb_column: Name of the JSONB column + + Returns: + list: Matching product IDs, or None if no filters + """ + if not attribute_filters: + return None + + conditions = [] + params = [] + + for attr_name, filter_value in attribute_filters.items(): + if isinstance(filter_value, dict): + # Range filter: {'min': x, 'max': y} + min_val = filter_value.get("min") + max_val = filter_value.get("max") + + if min_val is not None: + conditions.append(f"(({jsonb_column})::jsonb->>%s)::numeric >= %s") + params.extend([attr_name, min_val]) + + if max_val is not None: + conditions.append(f"(({jsonb_column})::jsonb->>%s)::numeric <= %s") + params.extend([attr_name, max_val]) + + elif isinstance(filter_value, list): + # Equality filter: [value1, value2, ...] + if len(filter_value) == 1: + conditions.append(f"({jsonb_column})::jsonb->>%s = %s") + params.extend([attr_name, filter_value[0]]) + elif len(filter_value) > 1: + placeholders = ", ".join(["%s"] * len(filter_value)) + conditions.append( + f"({jsonb_column})::jsonb->>%s IN ({placeholders})" + ) + params.append(attr_name) + params.extend(filter_value) + + if not conditions: + return None + + where_clause = " AND ".join(conditions) + # Where clause uses %s placeholders, actual values are in params list + query = f""" + SELECT id FROM product_template + WHERE active = true + AND sale_ok = true + AND {where_clause} + """ # nosec B608 + + self.env.cr.execute(query, params) + return [row[0] for row in self.env.cr.fetchall()] + + @api.model + def get_website_jsonb_attributes(self): + """Get all website-visible JSONB attributes for filtering. + + Returns: + recordset: attribute.attribute records configured for website filtering + """ + return self.env["attribute.attribute"].search( + [ + ("model", "=", "product.template"), + ("serialized", "=", True), + ("website_visible", "=", True), + ], + order="website_sequence, name", + ) diff --git a/website_attribute_set_jsonb/pyproject.toml b/website_attribute_set_jsonb/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/website_attribute_set_jsonb/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/website_attribute_set_jsonb/readme/CONFIGURE.md b/website_attribute_set_jsonb/readme/CONFIGURE.md new file mode 100644 index 000000000..eefffed70 --- /dev/null +++ b/website_attribute_set_jsonb/readme/CONFIGURE.md @@ -0,0 +1,41 @@ +## Prerequisites + +1. Install `attribute_set` from OCA/odoo-pim +2. Install `base_sparse_field_jsonb` for JSONB support +3. Create serialized attributes on `product.template` + +## Index Configuration + +For optimal performance, configure indexes based on filter type: + +### Equality Filters (checkbox, dropdown) +Set **Index Type** to **GIN (equality/containment)** on the attribute. + +### Range Filters +Set **Index Type** to **B-tree (range queries)** on the attribute. + +## Template Customization + +The filter template can be customized by inheriting: +- `website_attribute_set_jsonb.jsonb_attributes_filter` +- `website_attribute_set_jsonb.products_jsonb_attributes` + +Example: +```xml + +``` + +## Styling + +Override SCSS variables or add custom styles: +```scss +.o_jsonb_attribute_filter { + .accordion-body { + max-height: 400px; // Increase max height + } +} +``` diff --git a/website_attribute_set_jsonb/readme/CONTRIBUTORS.md b/website_attribute_set_jsonb/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..145b193e8 --- /dev/null +++ b/website_attribute_set_jsonb/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +* OBS Solutions B.V. diff --git a/website_attribute_set_jsonb/readme/CREDITS.md b/website_attribute_set_jsonb/readme/CREDITS.md new file mode 100644 index 000000000..23bf1d14d --- /dev/null +++ b/website_attribute_set_jsonb/readme/CREDITS.md @@ -0,0 +1,7 @@ +## Development + +This module was developed by OBS Solutions B.V. + +## Sponsors + +* International Heavy Equipment Dealer (Netherlands) diff --git a/website_attribute_set_jsonb/readme/DESCRIPTION.md b/website_attribute_set_jsonb/readme/DESCRIPTION.md new file mode 100644 index 000000000..f83fa7038 --- /dev/null +++ b/website_attribute_set_jsonb/readme/DESCRIPTION.md @@ -0,0 +1,29 @@ +This module integrates OCA's `attribute_set` JSONB serialized attributes with +Odoo's website shop filtering system. + +## Features + +- **Website Visibility Configuration**: Mark JSONB attributes as visible in + website shop filters +- **Multiple Filter Types**: Support for checkbox, dropdown, and range slider + filters +- **Facet Counting**: Displays count of products matching each filter value +- **Range Queries**: Efficient range filtering for numeric/date attributes + using B-tree indexes +- **URL Parameter Preservation**: Filter selections persist across pagination + and sorting +- **Active Filter Tags**: Visual display of currently applied filters with + easy removal + +## Use Cases + +- Heavy equipment dealers: Filter machines by capacity, year, brand +- E-commerce sites: Filter products by custom dynamic attributes +- B2B portals: Filter by technical specifications stored in JSONB + +## Performance + +This module uses PostgreSQL JSONB operators directly for efficient filtering: +- GIN indexes for equality/containment queries +- B-tree expression indexes for range queries (>, <, BETWEEN) +- Direct SQL for facet counting to avoid ORM overhead diff --git a/website_attribute_set_jsonb/readme/USAGE.md b/website_attribute_set_jsonb/readme/USAGE.md new file mode 100644 index 000000000..f2b12ff0b --- /dev/null +++ b/website_attribute_set_jsonb/readme/USAGE.md @@ -0,0 +1,43 @@ +## Configuration + +1. Navigate to **Settings > Technical > Attributes > Attributes** +2. Select a serialized attribute you want to show in website filters +3. Enable **Show in Website Filters** +4. Choose the **Filter Display Type**: + - **Checkbox**: Multiple selection with checkboxes + - **Dropdown**: Single selection dropdown + - **Range Slider**: For numeric/date attributes with min/max input +5. Set the **Website Filter Sequence** to control display order + +## Filter Types + +### Checkbox Filter +Best for attributes with multiple discrete values where users may want to +select multiple options: +- Brand: Caterpillar, Komatsu, Volvo +- Color: Red, Blue, Green + +### Dropdown Filter +Best for single-selection attributes or when there are many values: +- Country of Origin +- Condition (New, Used, Refurbished) + +### Range Filter +Best for numeric attributes where users want to filter by range: +- Capacity (kg): 1000 - 5000 +- Manufacturing Year: 2020 - 2024 +- Operating Weight: 10 - 50 tons + +**Note**: Range filters work best when the attribute has a B-tree index +configured in `base_sparse_field_jsonb`. + +## URL Parameters + +Filter selections are passed as URL parameters: +- Equality: `?jsonb_x_brand=caterpillar,komatsu` +- Range: `?jsonb_range_x_capacity=1000-5000` + +This allows for: +- Bookmarkable filtered URLs +- SEO-friendly filter pages +- Integration with external marketing tools diff --git a/website_attribute_set_jsonb/static/description/icon.png b/website_attribute_set_jsonb/static/description/icon.png new file mode 100644 index 000000000..1dcc49c24 Binary files /dev/null and b/website_attribute_set_jsonb/static/description/icon.png differ diff --git a/website_attribute_set_jsonb/static/src/scss/website_attribute_set.scss b/website_attribute_set_jsonb/static/src/scss/website_attribute_set.scss new file mode 100644 index 000000000..ed4b3afe2 --- /dev/null +++ b/website_attribute_set_jsonb/static/src/scss/website_attribute_set.scss @@ -0,0 +1,57 @@ +/* Website Attribute Set JSONB - Filter Styles */ + +.o_jsonb_attribute_filter { + .accordion-body { + max-height: 300px; + overflow-y: auto; + } + + .form-check { + padding-left: 1.5rem; + + .form-check-label { + cursor: pointer; + font-size: 0.9rem; + + .badge { + font-size: 0.75rem; + font-weight: normal; + } + } + } + + .o_jsonb_range_filter { + .form-control-sm { + font-size: 0.85rem; + } + + .o_range_min_display, + .o_range_max_display { + font-weight: 500; + } + } +} + +.o_active_jsonb_filters { + .badge { + font-weight: normal; + + a { + text-decoration: none; + opacity: 0.8; + + &:hover { + opacity: 1; + } + } + } +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .o_jsonb_attribute_filter { + .accordion-body { + max-height: 200px; + } + } +} diff --git a/website_attribute_set_jsonb/tests/__init__.py b/website_attribute_set_jsonb/tests/__init__.py new file mode 100644 index 000000000..8f53408f5 --- /dev/null +++ b/website_attribute_set_jsonb/tests/__init__.py @@ -0,0 +1 @@ +from . import test_website_jsonb_filters diff --git a/website_attribute_set_jsonb/tests/test_website_jsonb_filters.py b/website_attribute_set_jsonb/tests/test_website_jsonb_filters.py new file mode 100644 index 000000000..7b3157e65 --- /dev/null +++ b/website_attribute_set_jsonb/tests/test_website_jsonb_filters.py @@ -0,0 +1,226 @@ +"""Tests for website JSONB attribute filtering.""" + +from odoo.tests.common import TransactionCase + + +class TestWebsiteJsonbFilters(TransactionCase): + """Test cases for website JSONB attribute filtering.""" + + @classmethod + def setUpClass(cls): + """Set up test fixtures.""" + super().setUpClass() + cls.AttributeAttribute = cls.env["attribute.attribute"] + cls.AttributeGroup = cls.env["attribute.group"] + cls.ProductTemplate = cls.env["product.template"] + cls.IrModel = cls.env["ir.model"] + + # Get product.template model + cls.product_model = cls.IrModel.search( + [("model", "=", "product.template")], limit=1 + ) + + # Create an attribute group for product.template + cls.test_group = cls.AttributeGroup.create( + { + "name": "Test Website Group", + "model_id": cls.product_model.id, + } + ) + + def _create_test_attribute( + self, + name, + attr_type="char", + website_visible=False, + website_filter_type="checkbox", + ): + """Helper to create a serialized test attribute.""" + return self.AttributeAttribute.create( + { + "name": f"x_{name}", + "field_description": f"Test {name.title()}", + "attribute_type": attr_type, + "model_id": self.product_model.id, + "attribute_group_id": self.test_group.id, + "serialized": True, + "website_visible": website_visible, + "website_filter_type": website_filter_type, + } + ) + + def test_website_visible_field(self): + """Test website_visible field on attribute.""" + attr = self._create_test_attribute("color", website_visible=True) + self.assertTrue(attr.website_visible) + + attr2 = self._create_test_attribute("hidden", website_visible=False) + self.assertFalse(attr2.website_visible) + + def test_website_filter_type_field(self): + """Test website_filter_type field options.""" + attr_checkbox = self._create_test_attribute( + "cb_attr", website_filter_type="checkbox" + ) + self.assertEqual(attr_checkbox.website_filter_type, "checkbox") + + attr_select = self._create_test_attribute( + "sel_attr", website_filter_type="select" + ) + self.assertEqual(attr_select.website_filter_type, "select") + + attr_range = self._create_test_attribute( + "range_attr", attr_type="integer", website_filter_type="range" + ) + self.assertEqual(attr_range.website_filter_type, "range") + + def test_website_sequence_field(self): + """Test website_sequence field for ordering.""" + attr1 = self._create_test_attribute("first", website_visible=True) + attr1.website_sequence = 5 + + attr2 = self._create_test_attribute("second", website_visible=True) + attr2.website_sequence = 10 + + # Search should return in sequence order + attrs = self.AttributeAttribute.search( + [ + ("model", "=", "product.template"), + ("website_visible", "=", True), + ], + order="website_sequence, name", + ) + + # First should come before second + self.assertTrue(attr1 in attrs) + self.assertTrue(attr2 in attrs) + + def test_get_website_jsonb_attributes(self): + """Test getting website-visible JSONB attributes.""" + # Create visible and hidden attributes + visible_attr = self._create_test_attribute("visible", website_visible=True) + hidden_attr = self._create_test_attribute("hidden", website_visible=False) + + # Get website attributes + website_attrs = self.ProductTemplate.get_website_jsonb_attributes() + + # Should include visible, exclude hidden + self.assertIn(visible_attr, website_attrs) + self.assertNotIn(hidden_attr, website_attrs) + + def test_range_filter_type_warning_for_char(self): + """Test that range filter shows warning for non-numeric types.""" + attr = self._create_test_attribute( + "char_range", attr_type="char", website_filter_type="checkbox" + ) + + # Change to range type + attr.website_filter_type = "range" + result = attr._onchange_website_filter_type() + + # Should return warning for char type + self.assertIsNotNone(result) + self.assertIn("warning", result) + + def test_range_filter_type_valid_for_integer(self): + """Test that range filter is valid for integer types.""" + attr = self._create_test_attribute( + "int_range", attr_type="integer", website_filter_type="checkbox" + ) + + # Set index type to btree for optimal range query performance + attr.index_type = "btree" + + # Change to range type + attr.website_filter_type = "range" + result = attr._onchange_website_filter_type() + + # Should not return warning for integer with btree index + self.assertIsNone(result) + + def test_get_distinct_values(self): + """Test _get_distinct_values method.""" + attr = self._create_test_attribute("color", website_visible=True) + + # Create products with different color values + products = [] + for color in ["red", "blue", "green", "red"]: # red appears twice + product = self.ProductTemplate.create( + { + "name": f"Test Product {color}", + "type": "consu", + } + ) + # Set the attribute value via x_custom_json_attrs + if hasattr(product, "x_custom_json_attrs"): + product.write({"x_custom_json_attrs": {attr.name: color}}) + products.append(product) + + # Get distinct values + distinct_values = attr._get_distinct_values() + + # Should return unique values + self.assertIsInstance(distinct_values, list) + # Values should be unique (red only once) + if distinct_values: + self.assertEqual(len(distinct_values), len(set(distinct_values))) + + def test_get_min_max_values(self): + """Test _get_min_max_values method for numeric attributes.""" + attr = self._create_test_attribute( + "capacity", attr_type="integer", website_visible=True + ) + + # Create products with different capacity values + products = [] + for capacity in [100, 500, 1000]: + product = self.ProductTemplate.create( + { + "name": f"Test Product {capacity}", + "type": "consu", + } + ) + if hasattr(product, "x_custom_json_attrs"): + product.write({"x_custom_json_attrs": {attr.name: capacity}}) + products.append(product) + + # Get min/max + min_val, max_val = attr._get_min_max_values() + + # Should return numeric range + if min_val is not None and max_val is not None: + self.assertLessEqual(min_val, max_val) + + def test_get_jsonb_attribute_domain_equality(self): + """Test building domain for equality filters.""" + # Create test attribute + attr = self._create_test_attribute("brand", website_visible=True) + + # Test domain building with equality filter + filters = {attr.name: ["caterpillar", "komatsu"]} + domain = self.ProductTemplate._get_jsonb_attribute_domain(filters) + + # Should return a valid domain + self.assertIsInstance(domain, list) + + def test_get_jsonb_attribute_domain_range(self): + """Test building domain for range filters.""" + # Create test attribute + attr = self._create_test_attribute( + "weight", attr_type="integer", website_visible=True + ) + + # Test domain building with range filter + filters = {attr.name: {"min": 1000, "max": 5000}} + domain = self.ProductTemplate._get_jsonb_attribute_domain(filters) + + # Should return a valid domain + self.assertIsInstance(domain, list) + + def test_empty_filters_return_empty_domain(self): + """Test that empty filters return empty domain.""" + domain = self.ProductTemplate._get_jsonb_attribute_domain({}) + self.assertEqual(domain, []) + + domain2 = self.ProductTemplate._get_jsonb_attribute_domain(None) + self.assertEqual(domain2, []) diff --git a/website_attribute_set_jsonb/views/attribute_attribute_views.xml b/website_attribute_set_jsonb/views/attribute_attribute_views.xml new file mode 100644 index 000000000..957ec2806 --- /dev/null +++ b/website_attribute_set_jsonb/views/attribute_attribute_views.xml @@ -0,0 +1,56 @@ + + + + + attribute.attribute.form.website.jsonb + attribute.attribute + + + + + + + + + + + + + + + + attribute.attribute.tree.website.jsonb + attribute.attribute + + + + + + + + + + + attribute.attribute.search.website.jsonb + attribute.attribute + + + + + + + + diff --git a/website_attribute_set_jsonb/views/templates.xml b/website_attribute_set_jsonb/views/templates.xml new file mode 100644 index 000000000..141e4f197 --- /dev/null +++ b/website_attribute_set_jsonb/views/templates.xml @@ -0,0 +1,288 @@ + + + + + + + + + + + + + +