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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Min:
+
+
+ Max:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ :
+
+
+
+
+
+
+
+
+ :
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+