diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 000000000..b175ab5e1 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,2 @@ +odoo-addon-attribute_set @ git+https://github.com/OCA/odoo-pim.git@refs/pull/226/head#subdirectory=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/README.rst b/website_attribute_set/README.rst new file mode 100644 index 000000000..ab5ef443e --- /dev/null +++ b/website_attribute_set/README.rst @@ -0,0 +1,110 @@ +.. 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 +===================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:70a22a010d1706666b49f94906da9a8c7273649e03b5fce69b741553bebb3799 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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 + :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 + :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 allows the user to display and select custom attribute +created by attribute_set module in website and e-commerce apps. + +Users can compare products based on their attributes and also their +additional attributes. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Steps to Enable Attributes in E-Commerce +---------------------------------------- + +1. Go to PIM → Attributes → Product Attribute and select one +2. Check the field **``E-Commerce Visibility``**. +3. Assign values to the attribute in the product view, and make sure + that the product is linked with an **``attribute_set``**. +4. Open the E-Commerce app and navigate to the product page. You should + see the additional attributes displayed there. + +Known issues / Roadmap +====================== + + + +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 +------- + +* Kencove + +Contributors +------------ + +- Mohamed Alkobrosli + +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-kobros-tech| image:: https://github.com/kobros-tech.png?size=40px + :target: https://github.com/kobros-tech + :alt: kobros-tech + +Current `maintainer `__: + +|maintainer-kobros-tech| + +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/__init__.py b/website_attribute_set/__init__.py new file mode 100644 index 000000000..35547ee80 --- /dev/null +++ b/website_attribute_set/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2025 Kencove (http://www.kencove.com). +# @author Mohamed Alkobrosli +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models +from . import controllers diff --git a/website_attribute_set/__manifest__.py b/website_attribute_set/__manifest__.py new file mode 100644 index 000000000..d9f0170b0 --- /dev/null +++ b/website_attribute_set/__manifest__.py @@ -0,0 +1,37 @@ +# Copyright 2025 Kencove (http://www.kencove.com). +# @author Mohamed Alkobrosli +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Website Attribute Set", + "version": "19.0.1.0.0", + "category": "Website/Website", + "license": "AGPL-3", + "author": "Kencove, Odoo Community Association (OCA)", + "maintainers": ["kobros-tech"], + "website": "https://github.com/OCA/odoo-pim", + "depends": [ + "attribute_set", + "product_attribute_set", + # "pim", + "website", + "website_sale", + "website_sale_comparison", + ], + "data": [ + "views/attribute_attribute_view.xml", + "views/variant_templates.xml", + "views/templates.xml", + "views/website_sale_comparison_template.xml", + ], + "demo": [ + "demo/website_attribute_demo.xml", + ], + "assets": { + "web.assets_frontend": [ + "website_attribute_set/static/src/js/additional_attributes_filter.js", + ], + }, + "installable": True, + "application": True, +} diff --git a/website_attribute_set/controllers/__init__.py b/website_attribute_set/controllers/__init__.py new file mode 100644 index 000000000..ddc879939 --- /dev/null +++ b/website_attribute_set/controllers/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2025 Kencove (http://www.kencove.com). +# @author Mohamed Alkobrosli +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import main diff --git a/website_attribute_set/controllers/main.py b/website_attribute_set/controllers/main.py new file mode 100644 index 000000000..8ae146351 --- /dev/null +++ b/website_attribute_set/controllers/main.py @@ -0,0 +1,585 @@ +# Copyright 2025 Kencove (http://www.kencove.com). +# @author Mohamed Alkobrosli +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import datetime + +from werkzeug.exceptions import NotFound + +from odoo import fields +from odoo.fields import Domain +from odoo.http import request, route +from odoo.models import BaseModel +from odoo.tools import float_round, groupby, lazy + +from odoo.addons.website.controllers.main import QueryURL +from odoo.addons.website_sale.controllers import main + + +class WebsiteSale(main.WebsiteSale): + @route() + def shop( # noqa: C901 + self, + page=0, + category=None, + search="", + min_price=0.0, + max_price=0.0, + ppg=False, + **post, + ): + if not request.website.has_ecommerce_access(): + return request.redirect("/web/login") + try: + min_price = float(min_price) + except ValueError: + min_price = 0 + try: + max_price = float(max_price) + except ValueError: + max_price = 0 + + Category = request.env["product.public.category"] + if category: + category = Category.search([("id", "=", int(category))], limit=1) + if not category or not category.can_access_from_current_website(): + raise NotFound() + else: + category = Category + + website = request.env["website"].get_current_website() + website_domain = website.website_domain() + if ppg: + try: + ppg = int(ppg) + post["ppg"] = ppg + except ValueError: + ppg = False + if not ppg: + ppg = website.shop_ppg or 20 + + ppr = website.shop_ppr or 4 + + gap = website.shop_gap or "16px" + + request_args = request.httprequest.args + attrib_list = request_args.getlist("attribute_value") + attrib_values = [[int(x) for x in v.split("-")] for v in attrib_list if v] + attributes_ids = {v[0] for v in attrib_values} + attrib_set = {v[1] for v in attrib_values} + if attrib_list: + post["attribute_value"] = attrib_list + + # analyze the url args to be used in filter and search + request_args = request.httprequest.args + additional_attrib_list = request_args.getlist("additional_attribute_value") + additional_attrib_values = [ + [x for x in v.split("-", maxsplit=1)] for v in additional_attrib_list if v + ] + additional_attrib_values = [ + [int(sublist[0]), sublist[1]] for sublist in additional_attrib_values + ] + additional_attrib_set = set( + (item[0], item[1]) for item in additional_attrib_values + ) + post["additional_attrib_set"] = additional_attrib_set + post["additional_attrib_values"] = additional_attrib_values + + # Parse range filter parameters (for numeric attributes) + additional_range_filters = {} + for key in request_args.keys(): + if key.startswith("additional_attr_min_"): + attr_id = int(key.replace("additional_attr_min_", "")) + if attr_id not in additional_range_filters: + additional_range_filters[attr_id] = {} + try: + additional_range_filters[attr_id]["min"] = float(request_args[key]) + except (ValueError, TypeError): + continue # Skip invalid numeric values + elif key.startswith("additional_attr_max_"): + attr_id = int(key.replace("additional_attr_max_", "")) + if attr_id not in additional_range_filters: + additional_range_filters[attr_id] = {} + try: + additional_range_filters[attr_id]["max"] = float(request_args[key]) + except (ValueError, TypeError): + continue # Skip invalid numeric values + post["additional_range_filters"] = additional_range_filters + + filter_by_tags_enabled = website.is_view_active( + "website_sale.filter_products_tags" + ) + if filter_by_tags_enabled: + tags = request_args.getlist("tags") + # Allow only numeric tag values to avoid internal error. + if tags and all(tag.isnumeric() for tag in tags): + post["tags"] = tags + tags = {int(tag) for tag in tags} + else: + post["tags"] = None + tags = {} + + keep = QueryURL( + "/shop", + **self._shop_get_query_url_kwargs( + category and int(category), search, min_price, max_price, **post + ), + ) + + now = datetime.timestamp(datetime.now()) + # In Odoo 19, pricelist is accessed through user context, not website + pricelist = request.env.user.property_product_pricelist + + if "website_sale_pricelist_time" in request.session: + # Check if we need to refresh the cached pricelist + pricelist_save_time = request.session["website_sale_pricelist_time"] + if pricelist_save_time < now - 60 * 60: + request.session.pop("website_sale_current_pl", None) + # Clear the session cache for pricelist + new_pricelist = request.env.user.property_product_pricelist + pricelist = new_pricelist + request.session["website_sale_pricelist_time"] = now + request.session["website_sale_current_pl"] = pricelist.id + else: + request.session["website_sale_pricelist_time"] = now + request.session["website_sale_current_pl"] = pricelist.id + + filter_by_price_enabled = website.is_view_active( + "website_sale.filter_products_price" + ) + if filter_by_price_enabled: + company_currency = website.company_id.sudo().currency_id + conversion_rate = request.env["res.currency"]._get_conversion_rate( + company_currency, + website.currency_id, + request.website.company_id, + fields.Date.today(), + ) + else: + conversion_rate = 1 + + url = "/shop" + if search: + post["search"] = search + + options = self._get_search_options( + category=category, + attrib_values=attrib_values, + min_price=min_price, + max_price=max_price, + conversion_rate=conversion_rate, + display_currency=website.currency_id, + **post, + ) + fuzzy_search_term, product_count, search_product = self._shop_lookup_products( + options, post, search, website + ) + + filter_by_price_enabled = website.is_view_active( + "website_sale.filter_products_price" + ) + if filter_by_price_enabled: + # Get min/max prices for the filter using a standard aggregate approach + Product = request.env["product.template"].with_context(bin_size=True) + domain = self._get_shop_domain( + search, + category, + attrib_values, + additional_attrib_values=post.get("additional_attrib_values"), + ) + + # Use the more robust aggregate method to get min/max prices + # This is the Odoo 19 compatible approach + try: + # Get min and max prices using search and aggregate + results = Product.search_read(domain, ["list_price"]) + if results: + list_prices = [ + r["list_price"] or 0 for r in results if r.get("list_price") + ] + if list_prices: + available_min_price = min(list_prices) + available_max_price = max(list_prices) + else: + available_min_price = available_max_price = 0 + else: + available_min_price = available_max_price = 0 + except (Exception, ValueError, TypeError): + # Fallback if the aggregate query fails for any reason + available_min_price = available_max_price = 0 + + if min_price or max_price: + # The if/else condition in the min_price / max_price value assignment + # tackles the case where we switch to a list of products with different + # available min / max prices than the ones set in the previous page. + # In order to have logical results and not yield empty product lists, + # the price filter is set to their respective available prices + # when the specified min exceeds the max, and / or + # the specified max is lower than the available min. + if min_price: + min_price = ( + min_price + if min_price <= available_max_price + else available_min_price + ) + post["min_price"] = min_price + if max_price: + max_price = ( + max_price + if max_price >= available_min_price + else available_max_price + ) + post["max_price"] = max_price + + ProductTag = request.env["product.tag"] + if filter_by_tags_enabled and search_product: + all_tags = ProductTag.search( + Domain.AND( + [ + [ + ("product_ids.is_published", "=", True), + ], + website_domain, + ] + ) + ) + else: + all_tags = ProductTag + + categs_domain = [("parent_id", "=", False)] + website_domain + if search: + search_categories = Category.search( + [("product_tmpl_ids", "in", search_product.ids)] + website_domain + ).parents_and_self + categs_domain.append(("id", "in", search_categories.ids)) + else: + search_categories = Category + categs = lazy(lambda: Category.search(categs_domain)) + + if category: + url = "/shop/category/{}".format(request.env["ir.http"]._slug(category)) + + pager = website.pager( + url=url, total=product_count, page=page, step=ppg, scope=5, url_args=post + ) + offset = pager["offset"] + products = search_product[offset : offset + ppg] + + # Compute product variants (required by Odoo 19 templates) + variants = ( + request.env["product.product"] + .sudo() + .browse(product._get_first_possible_variant_id() for product in products) + ) + variants.fetch() + product_variants = dict(zip(products, variants, strict=False)) + + # Get product query params for attribute previews + product_query_params = self._get_product_query_params(**post) + + # Category entries for navigation + if category: + category_entries = category.child_id + else: + category_entries = categs + + ProductAttribute = request.env["product.attribute"] + if products: + # get all products without limit + attributes = lazy( + lambda: ProductAttribute.search( + [ + ("product_tmpl_ids", "in", search_product.ids), + ("visibility", "=", "visible"), + ] + ) + ) + else: + attributes = lazy(lambda: ProductAttribute.browse(attributes_ids)) + + layout_mode = request.session.get("website_sale_shop_layout_mode") + if not layout_mode: + if website.viewref("website_sale.products_list_view").active: + layout_mode = "list" + else: + layout_mode = "grid" + request.session["website_sale_shop_layout_mode"] = layout_mode + + products_prices = products._get_sales_prices(website) + + attributes_values = request.env["product.attribute.value"].browse(attrib_set) + sorted_attributes_values = attributes_values.sorted("sequence") + multi_attributes_values = sorted_attributes_values.filtered( + lambda av: av.display_type == "multi" + ) + single_attributes_values = sorted_attributes_values - multi_attributes_values + grouped_attributes_values = list( + groupby(single_attributes_values, lambda av: av.attribute_id.id) + ) + grouped_attributes_values.extend( + [(av.attribute_id.id, [av]) for av in multi_attributes_values] + ) + + selected_attributes_hash = ( + "#attribute_values={}".format( + ",".join(str(v[0].id) for k, v in grouped_attributes_values) + ) + if grouped_attributes_values + else "" + ) + + values = { + "search": fuzzy_search_term or search, + "original_search": fuzzy_search_term and search, + "order": post.get("order", ""), + "category": category, + "category_entries": category_entries, + "attrib_values": attrib_values, + "attrib_set": attrib_set, + "additional_attrib_set": additional_attrib_set, + "pager": pager, + "products": products, + "product_variants": product_variants, + "previewed_attribute_values": lazy( + lambda: products._get_previewed_attribute_values( + category, product_query_params + ) + ), + "search_product": search_product, + "search_count": product_count, # common for all searchbox + "bins": main.TableCompute().process(products, ppg, ppr), + "ppg": ppg, + "ppr": ppr, + "gap": gap, + "categories": categs, + "attributes": attributes, + "keep": keep, + "selected_attributes_hash": selected_attributes_hash, + "search_categories_ids": search_categories.ids, + "layout_mode": layout_mode, + "products_prices": products_prices, + "get_product_prices": lambda product: products_prices[product.id], + "float_round": float_round, + } + if filter_by_price_enabled: + values["min_price"] = min_price or available_min_price + values["max_price"] = max_price or available_max_price + values["available_min_price"] = float_round(available_min_price, 2) + values["available_max_price"] = float_round(available_max_price, 2) + if filter_by_tags_enabled: + values.update({"all_tags": all_tags, "tags": tags}) + if category: + values["main_object"] = category + values.update(self._get_additional_shop_values(values)) + + return request.render("website_sale.products", values) + + def _get_search_options( + self, + category=None, + attrib_values=None, + tags=None, + min_price=0.0, + max_price=0.0, + conversion_rate=1, + **post, + ): + values = super()._get_search_options( + category=category, + attrib_values=attrib_values, + tags=tags, + min_price=min_price, + max_price=max_price, + conversion_rate=conversion_rate, + **post, + ) + if post.get("additional_attrib_values"): + values["additional_attrib_values"] = post.get("additional_attrib_values") + return values + + def _get_additional_shop_values(self, values): + # Can be used to search & filter products depending on their custom attributes + """Hook to update values used for rendering website_sale.products template""" + extra_values = super()._get_additional_shop_values(values) + extra_values.update( + { + "additional_attributes": [], + } + ) + products = values.get("products") + search_product = values.get( + "search_product" + ) # All matching products for counts + all_additional_attributes = request.env["attribute.attribute"].sudo() + if products: + # loop to get all attributes that only haves values + # that can be displayed in e-commerce website + for product in products: + additional_attributes = product.sudo().get_extra_attributes() + if additional_attributes: + all_additional_attributes |= additional_attributes + + if all_additional_attributes: + # loop to get all assigned attribute values for all related products + for attribute in all_additional_attributes: + all_attribute_values = set() + value_counts = {} + + # Use search_product for counting if available, else use products + count_products = search_product or products + + for product in count_products: + attribute_values = product.sudo().get_extra_attribute_values( + attribute + ) + if attribute_values: + # To avoid repeatition of select options in the template + # We make sure if the attribute_values is a single value or + # if it is a recordset we loop through it + if ( + isinstance(attribute_values, BaseModel) + and len(attribute_values) > 1 + ): + for rec in attribute_values: + all_attribute_values.add(rec) + # Count products per value (use id for records) + if attribute.e_com_show_count: + key = rec.id if hasattr(rec, "id") else rec + value_counts[key] = value_counts.get(key, 0) + 1 + else: + all_attribute_values.add(attribute_values) + # Count products per value + if attribute.e_com_show_count: + if hasattr(attribute_values, "id"): + key = attribute_values.id + else: + key = attribute_values + value_counts[key] = value_counts.get(key, 0) + 1 + + attr_dict = { + "attribute": attribute, + "all_attribute_values": list(all_attribute_values), + } + if attribute.e_com_show_count: + attr_dict["value_counts"] = value_counts + extra_values["additional_attributes"].append(attr_dict) + + return extra_values + + def _get_shop_domain(self, search, category, attrib_values, **post): + """Extend shop domain with additional attribute filters.""" + additional_attrib_values = post.get("additional_attrib_values", []) + additional_range_filters = post.get("additional_range_filters", {}) + + domain = super()._get_shop_domain(search, category, attrib_values) + additional_conditions = [] + + # Handle range and value filters + additional_conditions.extend( + self._build_range_filter_conditions(additional_range_filters) + ) + additional_conditions.extend( + self._build_value_filter_conditions(additional_attrib_values) + ) + + if additional_conditions: + return Domain.AND([domain] + additional_conditions) + return domain + + def _build_range_filter_conditions(self, range_filters): + """Build domain conditions for range filters (min/max).""" + conditions = [] + Attribute = request.env["attribute.attribute"].sudo() + for attr_id, range_vals in range_filters.items(): + attribute = Attribute.browse(attr_id) + if not attribute.exists(): + continue + field_name = attribute.name + if "min" in range_vals: + conditions.append((field_name, ">=", range_vals["min"])) + if "max" in range_vals: + conditions.append((field_name, "<=", range_vals["max"])) + return conditions + + def _build_value_filter_conditions(self, attrib_values): + """Build domain conditions for value filters (select, boolean, etc.).""" + if not attrib_values: + return [] + + conditions = [] + Attribute = request.env["attribute.attribute"].sudo() + + # Group values by attribute for multi-select support + attr_values_grouped = {} + for attr_id, attr_value in attrib_values: + attr_values_grouped.setdefault(attr_id, []).append(attr_value) + + for attr_id, values in attr_values_grouped.items(): + attribute = Attribute.browse(attr_id) + if not attribute.exists(): + continue + + field_name = attribute.name + attr_type = attribute.attribute_type + + # Multi-select: OR within same attribute + if len(values) > 1 and attribute.e_com_multi_select: + or_conds = [ + c + for v in values + if (c := self._build_attribute_condition(field_name, attr_type, v)) + ] + if or_conds: + conditions.append(Domain.OR(or_conds)) + else: + for attr_value in values: + cond = self._build_attribute_condition( + field_name, attr_type, attr_value + ) + if cond: + conditions.append(cond) + return conditions + + def _build_attribute_condition(self, field_name, attr_type, attr_value): + """Build a single domain condition for an attribute value.""" + if attr_type == "boolean": + value = attr_value.lower() == "true" + return [(field_name, "=", value)] + elif attr_type in ("select", "multiselect"): + try: + option_id = int(attr_value) + return [(field_name, "=", option_id)] + except (ValueError, TypeError): + return None + elif attr_type == "integer": + try: + value = int(attr_value) + return [(field_name, "=", value)] + except (ValueError, TypeError): + return None + elif attr_type == "float": + try: + value = float(attr_value) + return [(field_name, "=", value)] + except (ValueError, TypeError): + return None + else: + # char, text, date, datetime - use exact match + return [(field_name, "=", attr_value)] + + def _prepare_product_values(self, product, category, **kwargs): + # If the product has a value for attribute_set_id + # this will pass the attributes related to it's attribute_set_id + # and then to be rendered in the website + vals = super()._prepare_product_values(product, category, **kwargs) + # Always set additional_attributes (even if empty) to ensure template + # variable exists + vals["additional_attributes"] = [] + extra_attributes = product.sudo().get_extra_attributes() + for attribute in extra_attributes: + attribute_values = product.sudo().get_extra_attribute_values(attribute) + if attribute_values: + vals["additional_attributes"].append( + {"attribute": attribute, "attribute_values": attribute_values} + ) + return vals diff --git a/website_attribute_set/demo/website_attribute_demo.xml b/website_attribute_set/demo/website_attribute_demo.xml new file mode 100644 index 000000000..1c70412df --- /dev/null +++ b/website_attribute_set/demo/website_attribute_demo.xml @@ -0,0 +1,161 @@ + + + + + Website Attribute Set Demo Group + + 10 + + + + + Website Attribute Set Demo + + + + + + custom + Demo Linux Compatible + x_was_demo_linux + boolean + + + + + + + + + + custom + Demo Brand + x_was_demo_brand + char + + + + + + + + + + custom + Demo Category Type + x_was_demo_cattype + select + + + + + + + + + + + + Laptop + + + + Desktop + + + + Accessory + + + + + + custom + Demo Warranty (Years) + x_was_demo_warranty + integer + + + + + + + + + + + custom + Demo Description + x_was_demo_desc + text + + + + + + + + + + Demo Linux Developer Laptop + consu + 1299.00 + + + + TechPro + + 3 + High-performance laptop pre-installed with Ubuntu Linux. Perfect for developers and open-source enthusiasts. Features 16GB RAM and 512GB NVMe SSD. + + + + + Demo Office Desktop Computer + consu + 899.00 + + + + OfficeMate + + 2 + Standard office desktop computer. Pre-configured for business applications. Includes 8GB RAM and 256GB SSD. + + + + + Demo Mechanical Keyboard + consu + 149.00 + + + + KeyMaster + + 1 + Premium mechanical keyboard with Cherry MX switches. Full Linux compatibility with customizable keys. + + + + + Demo Budget Linux Laptop + consu + 599.00 + + + + ValueTech + + 1 + Affordable laptop that works great with Linux distributions. 4GB RAM, 128GB SSD. Perfect for learning Linux. + + diff --git a/website_attribute_set/models/__init__.py b/website_attribute_set/models/__init__.py new file mode 100644 index 000000000..a43df60b5 --- /dev/null +++ b/website_attribute_set/models/__init__.py @@ -0,0 +1,10 @@ +# Copyright 2025 Kencove (http://www.kencove.com). +# @author Mohamed Alkobrosli +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import mixins +from . import attribute_attribute +from . import attribute_set_owner +from . import product_template +from . import product_product +from . import website diff --git a/website_attribute_set/models/attribute_attribute.py b/website_attribute_set/models/attribute_attribute.py new file mode 100644 index 000000000..c9ec4e4b6 --- /dev/null +++ b/website_attribute_set/models/attribute_attribute.py @@ -0,0 +1,114 @@ +# Copyright 2025 Kencove (http://www.kencove.com). +# @author Mohamed Alkobrosli +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo import api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools.safe_eval import safe_eval + + +class AttributeAttribute(models.Model): + _inherit = "attribute.attribute" + + e_com_visibility = fields.Boolean( + string="E-Commerce Visibility", + default=False, + help="""If selected the attribute will be shown in e-commerce website app.""", + ) + e_com_searchable = fields.Boolean( + string="E-Commerce Searchable", + default=False, + help="""If selected the attribute will be included in e-commerce search. + Disable for large text fields to improve search performance.""", + ) + e_com_range_filter = fields.Boolean( + string="E-Commerce Range Filter", + default=False, + help="""For numeric attributes (integer/float), show min/max range inputs + instead of individual value selection in the shop filter.""", + ) + e_com_multi_select = fields.Boolean( + string="E-Commerce Multi-Select", + default=False, + help="""Allow selecting multiple values for this attribute filter. + Products matching ANY selected value will be shown (OR logic).""", + ) + e_com_show_count = fields.Boolean( + string="E-Commerce Show Count", + default=False, + help="""Show the number of matching products next to each filter option.""", + ) + + def write(self, vals): + """Clear attribute cache when visibility or attribute sets change.""" + res = super().write(vals) + if any( + field in vals + for field in ("e_com_visibility", "attribute_set_ids", "nature", "model_id") + ): + # Clear the cache for e-commerce visible attributes + self.env.registry.clear_cache() + return res + + @api.model_create_multi + def create(self, vals_list): + """Clear attribute cache when new attributes are created.""" + res = super().create(vals_list) + if any( + vals.get("e_com_visibility") or vals.get("attribute_set_ids") + for vals in vals_list + ): + self.env.registry.clear_cache() + return res + + def unlink(self): + """Clear attribute cache when attributes are deleted.""" + res = super().unlink() + self.env.registry.clear_cache() + return res + + @api.constrains("domain") + def _validate_domain(self): + """Validate that the domain input is a valid Odoo domain.""" + for record in self: + if record.domain: + try: + domain = safe_eval(record.domain) + if not isinstance(domain, list): + continue + + if not domain: # Empty domain is valid + continue + + for i, element in enumerate(domain): + if isinstance(element, str) and element in ["|", "&", "!"]: + if i > 0: + prev_element = domain[i - 1] + if isinstance(prev_element, list | tuple): + raise ValueError( + f"'{element}' at pos {i} wrong position." + f"Operators must precede exprs." + ) + elif isinstance(element, list | tuple): + if len(element) < 2 or len(element) > 3: + raise ValueError( + f"Domain at pos {i}, need 2-3, got {len(element)}" + ) + field, operator = element[0], element[1] + if not isinstance(field, str): + raise ValueError( + f"Field at pos {i}, must be str, got {type(field)}" + ) + if not isinstance(operator, str): + raise ValueError( + f"Op at pos {i}, must be str, got {type(operator)}" + ) + else: + raise ValueError( + f"Domain elem must be op/cond list, got {type(element)}" + ) + except (Exception, TypeError, ValueError) as e: + raise ValidationError( + self.env._("Invalid domain: %s", str(e)) + ) from e diff --git a/website_attribute_set/models/attribute_set_owner.py b/website_attribute_set/models/attribute_set_owner.py new file mode 100644 index 000000000..87674152c --- /dev/null +++ b/website_attribute_set/models/attribute_set_owner.py @@ -0,0 +1,45 @@ +# Copyright 2025 Kencove (http://www.kencove.com). +# @author Mohamed Alkobrosli +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, models +from odoo.tools import ormcache + + +class AttributeSetOwnerMixin(models.AbstractModel): + """Mixin for consumers of attribute sets.""" + + _inherit = "attribute.set.owner.mixin" + + @api.model + @ormcache("model_name", "attribute_set_id", "include_native") + def _get_ecom_visible_attribute_ids( + self, model_name, attribute_set_id, include_native + ): + """Cached lookup of e-commerce visible attribute IDs for an attribute set. + + This avoids repeated database queries when displaying multiple products + with the same attribute set on the shop page. + """ + domain = [ + ("model", "=", model_name), + ("attribute_set_ids", "in", [attribute_set_id]), + ("e_com_visibility", "=", True), + ] + if not include_native: + domain.append(("nature", "=", "custom")) + return self.env["attribute.attribute"].search(domain).ids + + def get_extra_attributes(self): + """Get extra product's attribute for e-commerce website.""" + self.ensure_one() + if not self.attribute_set_id: + return self.env["attribute.attribute"] + + include_native = self.env.context.get( + "include_native_attribute_view_ref", False + ) + attr_ids = self._get_ecom_visible_attribute_ids( + self._name, self.attribute_set_id.id, include_native + ) + return self.env["attribute.attribute"].browse(attr_ids) diff --git a/website_attribute_set/models/mixins.py b/website_attribute_set/models/mixins.py new file mode 100644 index 000000000..c266f0c77 --- /dev/null +++ b/website_attribute_set/models/mixins.py @@ -0,0 +1,69 @@ +# Copyright 2025 Kencove (http://www.kencove.com). +# @author Mohamed Alkobrosli +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +from difflib import SequenceMatcher + +from odoo import api, models +from odoo.fields import Domain + +_logger = logging.getLogger(__name__) + + +def search_extra(env, search_term): + extra_domains = [] + attributes = ( + env["attribute.attribute"].sudo().search([("e_com_searchable", "=", True)]) + ) + for attribute in attributes: + if attribute.attribute_type in ["char", "text"]: + extra_domain = [(attribute.name, "ilike", search_term)] + extra_domains.append(extra_domain) + elif attribute.attribute_type in ["integer", "float"]: + try: + if attribute.attribute_type == "integer": + extra_domain = [(attribute.name, "ilike", int(search_term))] + extra_domains.append(extra_domain) + elif attribute.attribute_type == "float": + extra_domain = [(attribute.name, "ilike", float(search_term))] + extra_domains.append(extra_domain) + except ValueError as e: + _logger.info(f"{e}") + elif attribute.relation_model_id and attribute.attribute_type in [ + "select", + "multiselect", + ]: + extra_domain = [(f"{attribute.name}.name", "ilike", search_term)] + extra_domains.append(extra_domain) + else: + similarity = ( + SequenceMatcher(None, attribute.field_description, search_term).ratio() + * 100 + ) + if similarity > 80: + extra_domain = [(attribute.name, "!=", False)] + extra_domains.append(extra_domain) + return Domain.OR(extra_domains) + + +class WebsiteSearchableMixin(models.AbstractModel): + _inherit = "website.searchable.mixin" + + @api.model + def _search_fetch(self, search_detail, search, limit, order): + results, count = super()._search_fetch( + search_detail=search_detail, search=search, limit=limit, order=order + ) + model = self.sudo() if search_detail.get("requires_sudo") else self + if model._name == "product.template": + fields = search_detail["search_fields"] + base_domain = search_detail["base_domain"] + domain = self._search_build_domain( + base_domain, search, fields, search_extra + ) + results = model.search( + domain, limit=limit, order=search_detail.get("order", order) + ) + count = model.search_count(domain) + return results, count diff --git a/website_attribute_set/models/product_product.py b/website_attribute_set/models/product_product.py new file mode 100644 index 000000000..79a820dba --- /dev/null +++ b/website_attribute_set/models/product_product.py @@ -0,0 +1,51 @@ +# Copyright 2025 Kencove (http://www.kencove.com). +# @author Mohamed Alkobrosli +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from collections import OrderedDict + +from odoo import models + + +class ProductProduct(models.Model): + _inherit = "product.product" + + def _prepare_additional_attributes_for_display(self): + """The returned groups are ordered following their default order. + + :return: OrderedDict [{ + attribute.group: OrderedDict [{ + attribute.attribute: OrderedDict [{ + product.product: value + }] + }] + }] + """ + attributes = self.env["attribute.attribute"] + for product in self: + attributes |= product.product_tmpl_id.get_extra_attributes() + groups = OrderedDict( + [(group, OrderedDict()) for group in attributes.attribute_group_id.sorted()] + ) + for attribute in attributes: + groups[attribute.attribute_group_id][attribute] = OrderedDict( + [ + ( + product, + product.product_tmpl_id.get_extra_attribute_values(attribute), + ) + for product in self + ] + ) + for product in groups[attribute.attribute_group_id][attribute]: + values = groups[attribute.attribute_group_id][attribute][product] + if isinstance(values, models.BaseModel): + if len(values) == 1: + groups[attribute.attribute_group_id][attribute][product] = ( + values.name + ) + elif len(values) > 1: + groups[attribute.attribute_group_id][attribute][product] = ( + values.mapped("name") + ) + return groups diff --git a/website_attribute_set/models/product_template.py b/website_attribute_set/models/product_template.py new file mode 100644 index 000000000..39e3b7bd9 --- /dev/null +++ b/website_attribute_set/models/product_template.py @@ -0,0 +1,15 @@ +# Copyright 2025 Kencove (http://www.kencove.com). +# @author Mohamed Alkobrosli +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + def get_extra_attribute_values(self, extra_attribute=None): + self.ensure_one() + if extra_attribute: + return self[extra_attribute.name] if self[extra_attribute.name] else None + return None diff --git a/website_attribute_set/models/website.py b/website_attribute_set/models/website.py new file mode 100644 index 000000000..5cc60c486 --- /dev/null +++ b/website_attribute_set/models/website.py @@ -0,0 +1,87 @@ +# Copyright 2025 Kencove (http://www.kencove.com). +# @author Mohamed Alkobrosli +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import re + +from odoo import api, models + + +class Website(models.Model): + _inherit = "website" + + @api.model + def _search_get_details(self, search_type, order, options): + additional_attrib_values = options.get("additional_attrib_values") + values = super()._search_get_details( + search_type=search_type, order=order, options=options + ) + if additional_attrib_values: + for value in values: + base_domain = value.get("base_domain") + for additional_attrib in additional_attrib_values: + attribute_id = additional_attrib[0] + attribute = ( + self.env["attribute.attribute"].sudo().browse(attribute_id) + ) + if not attribute.exists(): + continue + attribute_field = attribute.name + attribute_type = attribute.attribute_type + additional_attrib_value = additional_attrib[1] + + # Handle different attribute types appropriately + if additional_attrib_value.startswith("name-"): + # Legacy format: name-model.name-id-123 + pattern = r"name-(.*?)-id-(\d+)" + match = re.search(pattern, additional_attrib_value) + if match: + model_name = match.group(1) + model_id = match.group(2) + search_rec_value = ( + self.env[model_name].sudo().browse(int(model_id)) + ) + additional_attrib_domain = [ + (attribute_field, "in", [search_rec_value.id]) + ] + base_domain.append(additional_attrib_domain) + elif attribute_type == "boolean": + # Boolean values come as "True" or "False" strings + bool_value = additional_attrib_value.lower() == "true" + additional_attrib_domain = [(attribute_field, "=", bool_value)] + base_domain.append(additional_attrib_domain) + elif attribute_type in ("select", "multiselect"): + # Select values are option IDs (integers) + try: + option_id = int(additional_attrib_value) + additional_attrib_domain = [ + (attribute_field, "=", option_id) + ] + base_domain.append(additional_attrib_domain) + except (ValueError, TypeError): + continue + elif attribute_type == "integer": + try: + int_value = int(additional_attrib_value) + additional_attrib_domain = [ + (attribute_field, "=", int_value) + ] + base_domain.append(additional_attrib_domain) + except (ValueError, TypeError): + continue + elif attribute_type == "float": + try: + float_value = float(additional_attrib_value) + additional_attrib_domain = [ + (attribute_field, "=", float_value) + ] + base_domain.append(additional_attrib_domain) + except (ValueError, TypeError): + continue + else: + # char, text, date, datetime - use exact match + additional_attrib_domain = [ + (attribute_field, "=", additional_attrib_value) + ] + base_domain.append(additional_attrib_domain) + return values diff --git a/website_attribute_set/pyproject.toml b/website_attribute_set/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/website_attribute_set/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/website_attribute_set/readme/CONTRIBUTORS.md b/website_attribute_set/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..60a4078f3 --- /dev/null +++ b/website_attribute_set/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Mohamed Alkobrosli \<\> diff --git a/website_attribute_set/readme/DESCRIPTION.md b/website_attribute_set/readme/DESCRIPTION.md new file mode 100644 index 000000000..328325673 --- /dev/null +++ b/website_attribute_set/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +This module allows the user to display and select custom attribute created by attribute_set module in website and e-commerce apps. + +Users can compare products based on their attributes and also their additional attributes. diff --git a/website_attribute_set/readme/ROADMAP.md b/website_attribute_set/readme/ROADMAP.md new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/website_attribute_set/readme/ROADMAP.md @@ -0,0 +1 @@ + diff --git a/website_attribute_set/readme/USAGE.md b/website_attribute_set/readme/USAGE.md new file mode 100644 index 000000000..c1179bc96 --- /dev/null +++ b/website_attribute_set/readme/USAGE.md @@ -0,0 +1,6 @@ +## Steps to Enable Attributes in E-Commerce + +1. Go to PIM → Attributes → Product Attribute and select one +2. Check the field **`E-Commerce Visibility`**. +3. Assign values to the attribute in the product view, and make sure that the product is linked with an **`attribute_set`**. +4. Open the E-Commerce app and navigate to the product page. You should see the additional attributes displayed there. diff --git a/website_attribute_set/static/description/icon.png b/website_attribute_set/static/description/icon.png new file mode 100644 index 000000000..ccf59a923 Binary files /dev/null and b/website_attribute_set/static/description/icon.png differ diff --git a/website_attribute_set/static/description/index.html b/website_attribute_set/static/description/index.html new file mode 100644 index 000000000..cf496407c --- /dev/null +++ b/website_attribute_set/static/description/index.html @@ -0,0 +1,456 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Website Attribute Set

+ +

Beta License: AGPL-3 OCA/odoo-pim Translate me on Weblate Try me on Runboat

+

This module allows the user to display and select custom attribute +created by attribute_set module in website and e-commerce apps.

+

Users can compare products based on their attributes and also their +additional attributes.

+

Table of contents

+ +
+

Usage

+
+

Steps to Enable Attributes in E-Commerce

+
    +
  1. Go to PIM → Attributes → Product Attribute and select one
  2. +
  3. Check the field ``E-Commerce Visibility``.
  4. +
  5. Assign values to the attribute in the product view, and make sure +that the product is linked with an ``attribute_set``.
  6. +
  7. Open the E-Commerce app and navigate to the product page. You should +see the additional attributes displayed there.
  8. +
+
+
+ +
+

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

+
    +
  • Kencove
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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.

+

Current maintainer:

+

kobros-tech

+

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/static/src/.eslintrc.json b/website_attribute_set/static/src/.eslintrc.json new file mode 100644 index 000000000..700be9ba4 --- /dev/null +++ b/website_attribute_set/static/src/.eslintrc.json @@ -0,0 +1,11 @@ +{ + "env": { + "browser": true, + "es2021": true + }, + "parserOptions": { + "ecmaVersion": 2021, + "sourceType": "module" + }, + "rules": {} +} diff --git a/website_attribute_set/static/src/js/.eslintrc.json b/website_attribute_set/static/src/js/.eslintrc.json new file mode 100644 index 000000000..700be9ba4 --- /dev/null +++ b/website_attribute_set/static/src/js/.eslintrc.json @@ -0,0 +1,11 @@ +{ + "env": { + "browser": true, + "es2021": true + }, + "parserOptions": { + "ecmaVersion": 2021, + "sourceType": "module" + }, + "rules": {} +} diff --git a/website_attribute_set/static/src/js/additional_attributes_filter.js b/website_attribute_set/static/src/js/additional_attributes_filter.js new file mode 100644 index 000000000..a2b4bc389 --- /dev/null +++ b/website_attribute_set/static/src/js/additional_attributes_filter.js @@ -0,0 +1,79 @@ +/** @odoo-module **/ + +import publicWidget from "@web/legacy/js/public/public_widget"; + +publicWidget.registry.AdditionalAttributesFilter = publicWidget.Widget.extend({ + selector: "#wsale_products_attributes_collapse", + events: { + "change select[name='additional_attribute_value']": + "_onAdditionalAttributeChange", + "change input[name='additional_attribute_value']": + "_onAdditionalAttributeChange", + "change input[name^='additional_attr_min_']": "_onRangeFilterChange", + "change input[name^='additional_attr_max_']": "_onRangeFilterChange", + }, + + /** + * Handle change events on additional attribute filter inputs. + * Submits the form to apply the filter. + * @param {Event} ev + */ + _onAdditionalAttributeChange: function (ev) { + const form = this.el.closest("form"); + if (form) { + form.submit(); + } else { + // Fallback: manually update URL with filter parameters + this._updateUrlWithFilters(); + } + }, + + /** + * Handle change events on range filter inputs (min/max). + * Submits the form to apply the range filter. + * @param {Event} ev + */ + _onRangeFilterChange: function (ev) { + const form = this.el.closest("form"); + if (form) { + form.submit(); + } else { + this._updateUrlWithFilters(); + } + }, + + /** + * Fallback method to update URL with filter parameters if no form found. + */ + _updateUrlWithFilters: function () { + const url = new URL(window.location.href); + const params = url.searchParams; + + // Remove existing additional_attribute_value params + params.delete("additional_attribute_value"); + + // Add current selections + const selects = this.el.querySelectorAll( + "select[name='additional_attribute_value']" + ); + selects.forEach((select) => { + if (select.value) { + params.append("additional_attribute_value", select.value); + } + }); + + const checkboxes = this.el.querySelectorAll( + "input[name='additional_attribute_value']:checked" + ); + checkboxes.forEach((checkbox) => { + if (checkbox.value) { + params.append("additional_attribute_value", checkbox.value); + } + }); + + // Navigate to the new URL + window.location.href = url.toString(); + }, +}); + +export default publicWidget.registry.AdditionalAttributesFilter; diff --git a/website_attribute_set/tests/__init__.py b/website_attribute_set/tests/__init__.py new file mode 100644 index 000000000..a90de4100 --- /dev/null +++ b/website_attribute_set/tests/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2025 Kencove (http://www.kencove.com). +# @author Mohamed Alkobrosli +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_website_attr_set +from . import test_controller diff --git a/website_attribute_set/tests/test_controller.py b/website_attribute_set/tests/test_controller.py new file mode 100644 index 000000000..aab9b7bae --- /dev/null +++ b/website_attribute_set/tests/test_controller.py @@ -0,0 +1,508 @@ +"""Unit tests for website_attribute_set controllers.""" + +from odoo.tests import HttpCase, tagged + + +@tagged("post_install", "-at_install") +class TestWebsiteAttributeController(HttpCase): + """Test class for website attribute set controllers.""" + + @classmethod + def setUpClass(cls): + """Set up test environment.""" + super().setUpClass() + cls.website = cls.env.ref("website.default_website") + cls.product_model = cls.env.ref("product.model_product_template") + + # Create attribute group + cls.attr_group = cls.env["attribute.group"].create( + { + "name": "Test E-Commerce Group", + "model_id": cls.product_model.id, + "sequence": 1, + } + ) + + # Create attribute set + cls.attr_set = cls.env["attribute.set"].create( + { + "name": "Test E-Commerce Attribute Set", + "model_id": cls.product_model.id, + } + ) + + # Create a text attribute with e-commerce visibility enabled + cls.attr_description = cls.env["attribute.attribute"].create( + { + "nature": "custom", + "field_description": "Product Description", + "name": "x_ecom_description", + "attribute_type": "text", + "attribute_group_id": cls.attr_group.id, + "attribute_set_ids": [(4, cls.attr_set.id)], + "model_id": cls.product_model.id, + "e_com_visibility": True, + } + ) + + # Create a char attribute with e-commerce visibility enabled + cls.attr_color = cls.env["attribute.attribute"].create( + { + "nature": "custom", + "field_description": "Product Color", + "name": "x_ecom_color", + "attribute_type": "char", + "attribute_group_id": cls.attr_group.id, + "attribute_set_ids": [(4, cls.attr_set.id)], + "model_id": cls.product_model.id, + "e_com_visibility": True, + } + ) + + # Create a select attribute with e-commerce visibility enabled + # This tests the .mapped('name') code path in the template + cls.attr_select = cls.env["attribute.attribute"].create( + { + "nature": "custom", + "field_description": "Product Material", + "name": "x_ecom_material", + "attribute_type": "select", + "attribute_group_id": cls.attr_group.id, + "attribute_set_ids": [(4, cls.attr_set.id)], + "model_id": cls.product_model.id, + "e_com_visibility": True, + } + ) + + # Create options for the select attribute + cls.material_option = cls.env["attribute.option"].create( + { + "name": "Cotton", + "attribute_id": cls.attr_select.id, + } + ) + + # Create a boolean attribute with e-commerce visibility enabled + cls.attr_boolean = cls.env["attribute.attribute"].create( + { + "nature": "custom", + "field_description": "Is Organic", + "name": "x_ecom_organic", + "attribute_type": "boolean", + "attribute_group_id": cls.attr_group.id, + "attribute_set_ids": [(4, cls.attr_set.id)], + "model_id": cls.product_model.id, + "e_com_visibility": True, + } + ) + + def setUp(self): + """Set up test environment.""" + super().setUp() + + def test_shop_page_accessibility(self): + """Test that the shop page can be accessed without errors.""" + # This test ensures that the controller methods don't throw errors + # like 'website' object has no attribute 'pricelist_id' + self.authenticate("admin", "admin") + response = self.url_open("/shop", timeout=30) + + # Should be able to access the shop page without errors + self.assertEqual(response.status_code, 200) + + def test_product_template_with_attributes_accessibility(self): + """Test that product pages with attributes can be accessed without errors.""" + # Ensure we can access product-related pages that use attribute functionality + self.authenticate("admin", "admin") + + # Check if we can access the product template form + response = self.url_open("/web", timeout=30) + self.assertEqual(response.status_code, 200) + + # Verify the website controller has the required methods to avoid AttributeError + from ..controllers.main import WebsiteSale + + website_sale = WebsiteSale() + + # Test that required methods exist + self.assertTrue(hasattr(website_sale, "shop")) + + def test_product_page_accessibility(self): + """Test that individual product pages can be accessed without 500 errors. + + This test specifically catches issues with _prepare_product_values signature + changes in Odoo 19 (removed 'search' parameter). + """ + self.authenticate("admin", "admin") + + # Create a published product to test the product page + product = self.env["product.template"].create( + { + "name": "Test Product for Website", + "is_published": True, + "website_id": self.website.id, + } + ) + + # Access the product page - this tests _prepare_product_values + response = self.url_open(f"/shop/{product.id}", timeout=30) + + # Should return 200, not 500 (Internal Server Error) + self.assertEqual( + response.status_code, + 200, + f"Product page returned {response.status_code}, expected 200. " + "Check _prepare_product_values signature matches Odoo 19.", + ) + + def test_product_page_displays_ecom_attributes(self): + """Test that e-commerce visible attributes are displayed on the product page. + + This test creates a product with an attribute set and e-commerce visible + attributes, then verifies that the attribute values appear in the + rendered product page HTML. + """ + self.authenticate("admin", "admin") + + # Create a product with attribute set and values + product = self.env["product.template"].create( + { + "name": "Test Product With Attributes", + "is_published": True, + "website_id": self.website.id, + "attribute_set_id": self.attr_set.id, + "x_ecom_description": "This is a test product description", + "x_ecom_color": "Blue", + } + ) + + # Verify the product has the attribute set + self.assertEqual(product.attribute_set_id, self.attr_set) + + # Verify get_extra_attributes returns our e-commerce visible attributes + extra_attrs = product.get_extra_attributes() + self.assertIn( + self.attr_description, + extra_attrs, + "E-commerce visible attribute should be returned by get_extra_attributes", + ) + self.assertIn( + self.attr_color, + extra_attrs, + "E-commerce visible attribute should be returned by get_extra_attributes", + ) + + # Access the product page + response = self.url_open(f"/shop/{product.id}", timeout=30) + + # Should return 200 + self.assertEqual(response.status_code, 200) + + # Verify the attribute values are in the page content + content = response.text + self.assertIn( + "This is a test product description", + content, + "Product description attribute value should appear on the product page", + ) + self.assertIn( + "Blue", + content, + "Product color attribute value should appear on the product page", + ) + + def test_shop_page_displays_additional_attributes_filter(self): + """Test that additional attributes appear in the shop filter. + + This test creates published products with e-commerce visible attributes + and verifies the filter options appear on the shop page. + """ + self.authenticate("admin", "admin") + + # Create a product with attribute set and values + self.env["product.template"].create( + { + "name": "Shop Filter Test Product", + "is_published": True, + "website_id": self.website.id, + "attribute_set_id": self.attr_set.id, + "x_ecom_description": "Filter test description", + "x_ecom_color": "Red", + } + ) + + # Access the shop page + response = self.url_open("/shop", timeout=30) + + # Should return 200 + self.assertEqual(response.status_code, 200) + + # The shop page should load without errors + # Note: The actual filter display depends on template implementation + content = response.text + self.assertNotIn( + "Internal Server Error", + content, + "Shop page should not have internal server errors", + ) + + def test_product_page_with_select_attribute(self): + """Test product page with select attribute type. + + This test ensures the template correctly handles select attributes + which use .mapped('name') to display values. This catches issues + like KeyError: 'hasattr' when QWeb tries to use Python builtins. + """ + self.authenticate("admin", "admin") + + # Create a product with select attribute + product = self.env["product.template"].create( + { + "name": "Test Product With Select Attribute", + "is_published": True, + "website_id": self.website.id, + "attribute_set_id": self.attr_set.id, + "x_ecom_material": self.material_option.id, + } + ) + + # Access the product page + response = self.url_open(f"/shop/{product.id}", timeout=30) + + # Should return 200, not 500 + self.assertEqual( + response.status_code, + 200, + f"Product page with select attribute returned {response.status_code}. " + "Check template doesn't use Python builtins like hasattr.", + ) + + # Verify the select attribute value appears in the page + content = response.text + self.assertIn( + "Cotton", + content, + "Select attribute value should appear on the product page", + ) + + def test_product_page_with_boolean_attribute(self): + """Test product page with boolean attribute type. + + This test ensures boolean attributes display correctly (Yes/No). + """ + self.authenticate("admin", "admin") + + # Create a product with boolean attribute set to True + product = self.env["product.template"].create( + { + "name": "Test Product With Boolean Attribute", + "is_published": True, + "website_id": self.website.id, + "attribute_set_id": self.attr_set.id, + "x_ecom_organic": True, + } + ) + + # Access the product page + response = self.url_open(f"/shop/{product.id}", timeout=30) + + # Should return 200 + self.assertEqual(response.status_code, 200) + + # Verify the boolean displays as Yes + content = response.text + self.assertIn( + "Yes", + content, + "Boolean True should display as 'Yes' on the product page", + ) + + def test_product_page_without_attribute_set(self): + """Test that product pages without attribute sets still work. + + This ensures the module doesn't break regular products that + don't have any attribute sets configured. + """ + self.authenticate("admin", "admin") + + # Create a product WITHOUT attribute set + product = self.env["product.template"].create( + { + "name": "Regular Product Without Attributes", + "is_published": True, + "website_id": self.website.id, + # Note: no attribute_set_id + } + ) + + # Access the product page + response = self.url_open(f"/shop/{product.id}", timeout=30) + + # Should return 200 + self.assertEqual( + response.status_code, + 200, + "Product page without attribute set should work normally", + ) + + # Verify no internal server error + self.assertNotIn( + "Internal Server Error", + response.text, + "Product page should not have internal server errors", + ) + + def test_shop_filter_applies_boolean_filter(self): + """Test that boolean attribute filter actually filters products. + + This is a critical test that verifies the _get_shop_domain override + correctly applies filters based on additional_attribute_value params. + """ + self.authenticate("admin", "admin") + + # Create a product with boolean attribute True + organic_product = self.env["product.template"].create( + { + "name": "Organic Product True", + "is_published": True, + "website_id": self.website.id, + "attribute_set_id": self.attr_set.id, + "x_ecom_organic": True, + } + ) + + # Create a product with boolean attribute False + self.env["product.template"].create( + { + "name": "Non-Organic Product False", + "is_published": True, + "website_id": self.website.id, + "attribute_set_id": self.attr_set.id, + "x_ecom_organic": False, + } + ) + + # Access shop with filter for x_ecom_organic=True + attr_id = self.attr_boolean.id + filter_url = f"/shop?additional_attribute_value={attr_id}-True" + response = self.url_open(filter_url, timeout=30) + + # Should return 200 + self.assertEqual(response.status_code, 200) + + content = response.text + + # Should NOT have internal server errors + self.assertNotIn( + "Internal Server Error", + content, + "Shop page with filter should not have internal server errors", + ) + + # The organic product should be in results + self.assertIn( + "Organic Product True", + content, + "Filtered results should include the organic product", + ) + + # Verify the page loads successfully with filter applied + # Note: The actual filtering depends on _get_shop_domain implementation + self.assertIn( + organic_product.name, + content, + "Product matching filter should appear in results", + ) + + def test_shop_filter_applies_select_filter(self): + """Test that select attribute filter actually filters products. + + This tests filtering by select/option-based attributes. + """ + self.authenticate("admin", "admin") + + # Create a product with the select attribute set to Cotton + cotton_product = self.env["product.template"].create( + { + "name": "Cotton Material Product", + "is_published": True, + "website_id": self.website.id, + "attribute_set_id": self.attr_set.id, + "x_ecom_material": self.material_option.id, + } + ) + + # Create a product without the material attribute + self.env["product.template"].create( + { + "name": "No Material Product", + "is_published": True, + "website_id": self.website.id, + "attribute_set_id": self.attr_set.id, + # x_ecom_material not set + } + ) + + # Access shop with filter for x_ecom_material=cotton_option_id + attr_id = self.attr_select.id + option_id = self.material_option.id + filter_url = f"/shop?additional_attribute_value={attr_id}-{option_id}" + response = self.url_open(filter_url, timeout=30) + + # Should return 200 + self.assertEqual(response.status_code, 200) + + content = response.text + + # Should NOT have internal server errors + self.assertNotIn( + "Internal Server Error", + content, + "Shop page with select filter should not have internal server errors", + ) + + # The cotton product should be in results + self.assertIn( + cotton_product.name, + content, + "Product with matching select value should appear in filtered results", + ) + + def test_shop_search_with_filter(self): + """Test that search combined with filter works without errors. + + This tests the _get_shop_domain call with search parameter to ensure + there are no conflicts between positional and keyword arguments. + """ + self.authenticate("admin", "admin") + + # Create a product with attribute + self.env["product.template"].create( + { + "name": "Searchable Organic Product", + "is_published": True, + "website_id": self.website.id, + "attribute_set_id": self.attr_set.id, + "x_ecom_organic": True, + } + ) + + # Access shop with both search and filter + attr_id = self.attr_boolean.id + filter_url = f"/shop?search=Searchable&additional_attribute_value={attr_id}-True" + response = self.url_open(filter_url, timeout=30) + + # Should return 200, not 500 + self.assertEqual( + response.status_code, + 200, + f"Shop page with search and filter returned {response.status_code}. " + "Check _get_shop_domain doesn't get duplicate 'search' argument.", + ) + + # Verify no internal server error + self.assertNotIn( + "Internal Server Error", + response.text, + "Shop page should not have internal server errors", + ) diff --git a/website_attribute_set/tests/test_website_attr_set.py b/website_attribute_set/tests/test_website_attr_set.py new file mode 100644 index 000000000..46ec46b64 --- /dev/null +++ b/website_attribute_set/tests/test_website_attr_set.py @@ -0,0 +1,278 @@ +# Copyright 2025 Kencove (http://www.kencove.com). +# @author Mohamed Alkobrosli +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import ValidationError + +from odoo.addons.attribute_set.tests.test_build_view import BuildViewCase +from odoo.addons.website_attribute_set.models.mixins import search_extra + + +class TestAttributeSetSearchable(BuildViewCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.product_model = cls.env.ref("product.model_product_template") + + # Create required attribute records directly for test compatibility + cls.group_1 = cls.env["attribute.group"].create( + { + "name": "Technical Group", + "model_id": cls.product_model.id, + "sequence": 1, + } + ) + + cls.attr_set_1 = cls.env["attribute.set"].create( + { + "name": "Computer Attribute Set", + "model_id": cls.product_model.id, + } + ) + + cls.attr_1 = cls.env["attribute.attribute"].create( + { + "nature": "custom", + "field_description": "Processor", + "name": "x_processor", + "attribute_type": "select", + "attribute_group_id": cls.group_1.id, + "attribute_set_ids": [(4, cls.attr_set_1.id)], + "model_id": cls.product_model.id, + } + ) + + cls.attr_2 = cls.env["attribute.attribute"].create( + { + "nature": "custom", + "field_description": "Technical Description", + "name": "x_technical_description", + "attribute_type": "text", + "attribute_group_id": cls.group_1.id, + "attribute_set_ids": [(4, cls.attr_set_1.id)], + "model_id": cls.product_model.id, + } + ) + cls.attr_3 = cls.env["attribute.attribute"].create( + { + "nature": "custom", + "field_description": "Hard Disk", + "name": "x_hard_disk", + "attribute_type": "select", + "attribute_group_id": cls.group_1.id, # Using group created directly + "attribute_set_ids": [ + ( + 4, + cls.attr_set_1.id, # Using the set we created directly + 0, + ) + ], + "model_id": cls.product_model.id, # Using the model we already have + "relation_model_id": cls.product_model.id, + } + ) + # Create an attribute with domain capabilities for domain validation test + cls.attr_select = cls.env["attribute.attribute"].create( + { + "nature": "custom", + "field_description": "Test Domain Attribute", + "name": "x_test_domain_attr", + "attribute_type": "select", + "attribute_group_id": cls.group_1.id, + "attribute_set_ids": [(4, cls.attr_set_1.id)], + "model_id": cls.product_model.id, + "relation_model_id": cls.product_model.id, + } + ) + + cls.product_1 = cls.env["product.template"].create( + { + "name": "Test Smart Product", + "type": "consu", + "attribute_set_id": cls.attr_set_1.id, + } + ) + + def test__validate_domain(self): + # Test invalid domain raises ValidationError from ir.model.fields constraint + # In Odoo 19, the domain field is validated by _check_domain constraint + with self.assertRaises(ValidationError): + self.attr_select.domain = "foo" + + # Test that a valid domain can be set without error + self.attr_select.domain = ["|", ["name", "!=", "foo"], ["name", "!=", "foo"]] + + # Test that a structurally invalid domain raises our custom ValidationError + # We create a new record and call the constraint method directly + # to ensure the validation logic is tested independent of framework behavior. + invalid_domain_list = [["name", "!=", "foo"], "|", ["name", "!=", "foo"]] + # The domain field is a Char, so the list is stored as its string representation + invalid_domain_str = str(invalid_domain_list) + record_with_invalid_domain = self.env["attribute.attribute"].new( + {"domain": invalid_domain_str} + ) + with self.assertRaises(ValidationError): + record_with_invalid_domain._validate_domain() + + # Test that other valid domains can be set + self.attr_select.domain = [("name", "!=", "foo")] + self.attr_select.domain = [] + + def test_get_extra_attributes(self): + # Assert the method returns no attributes if they are not visible in e-com app + extra_attrs = self.product_1.get_extra_attributes() + self.assertFalse(extra_attrs) + # Assert the method returns only the attributes that are visible in e-com app + # Create a test option for the processor attribute + test_option = self.env["attribute.option"].create( + { + "name": "Intel i7", + "attribute_id": self.attr_1.id, + } + ) + self.product_1.x_processor = test_option + self.product_1.write({"x_technical_description": "Fast processor"}) + self.attr_1.write({"e_com_visibility": True}) + extra_attrs = self.product_1.get_extra_attributes() + self.assertTrue( + len(extra_attrs) == 1 and extra_attrs.mapped("name") == ["x_processor"] + ) + self.attr_2.write({"e_com_visibility": True}) + extra_attrs = self.product_1.get_extra_attributes() + self.assertTrue( + len(extra_attrs) == 2 + and extra_attrs.mapped("name") == ["x_processor", "x_technical_description"] + ) + + def test_search_extra(self): + # attributes are not searchable in e-com + domain = search_extra(self.env, "Fast processor") + self.assertEqual(list(domain), [(0, "=", 1)]) + # attributes are searchable in e-com but + # if they are select or multi-select then + # they need relation_model_id value + self.attr_1.write({"e_com_searchable": True}) + domain = search_extra(self.env, "Fast processor") + self.assertEqual(list(domain), [(0, "=", 1)]) + # attributes are searchable in e-com + self.attr_2.write({"e_com_searchable": True}) + domain = search_extra(self.env, "Fast processor") + self.assertEqual( + list(domain), [("x_technical_description", "ilike", "Fast processor")] + ) + # select, multi-select attributes are searchable in e-com as + # they have relation_model_id value + self.attr_3.write({"e_com_searchable": True}) + domain = search_extra(self.env, "Fast processor") + self.assertEqual( + list(domain), + [ + "|", + ("x_hard_disk.name", "ilike", "Fast processor"), + ("x_technical_description", "ilike", "Fast processor"), + ], + ) + + def test_e_com_searchable_vs_visibility(self): + """Test that e_com_searchable controls search, not e_com_visibility.""" + # Set attribute visible but NOT searchable + self.attr_2.write({"e_com_visibility": True, "e_com_searchable": False}) + domain = search_extra(self.env, "Fast processor") + # Should NOT include this attribute in search + self.assertEqual(list(domain), [(0, "=", 1)]) + + # Now make it searchable + self.attr_2.write({"e_com_searchable": True}) + domain = search_extra(self.env, "Fast processor") + # Should include this attribute in search + self.assertEqual( + list(domain), [("x_technical_description", "ilike", "Fast processor")] + ) + + def test__search_fetch(self): + self.product_1.write({"x_technical_description": "Fast processor"}) + custom_domain = [ + "&", + "&", + ("sale_ok", "=", True), + ("website_id", "in", (False, 1)), + "|", + "|", + "|", + ("name", "ilike", "Fast"), + ("default_code", "ilike", "Fast"), + ("product_variant_ids.default_code", "ilike", "Fast"), + "|", + ("x_hard_disk.name", "ilike", "Fast"), + ("x_technical_description", "ilike", "Fast"), + ] + result = self.env["product.template"].search(custom_domain) + # custom attributes don't appear in e-com search of we don't set visibility + results = ( + self.env["website"] + .browse(1) + ._search_with_fuzzy( + "all", + "Fast", + limit=5, + order="name asc, website_id desc, id", + options={ + "displayDescription": False, + "displayDetail": False, + "displayExtraDetail": False, + "displayExtraLink": False, + "displayImage": False, + "allowFuzzy": True, + }, + ) + ) + for i in results[1]: + self.assertEqual(i["count"], 0) + # custom attributes appear in e-com search if we set searchable + self.attr_2.write({"e_com_searchable": True}) + self.attr_3.write({"e_com_searchable": True}) + results = ( + self.env["website"] + .browse(1) + ._search_with_fuzzy( + "all", + "Fast", + limit=5, + order="name asc, website_id desc, id", + options={ + "displayDescription": False, + "displayDetail": False, + "displayExtraDetail": False, + "displayExtraLink": False, + "displayImage": False, + "allowFuzzy": True, + }, + ) + ) + for i in results[1]: + if i["count"] > 0: + self.assertEqual(i["count"], 1) + self.assertEqual(i["results"].mapped("name"), ["Test Smart Product"]) + self.assertEqual(i["results"], result) + + def test_get_extra_attribute_values(self): + extra_attribute_values = self.product_1.get_extra_attribute_values(self.attr_2) + self.assertEqual(extra_attribute_values, None) + self.product_1.write({"x_technical_description": "Fast processor"}) + extra_attribute_values = self.product_1.get_extra_attribute_values(self.attr_2) + self.assertEqual(extra_attribute_values, "Fast processor") + + def test__prepare_additional_attributes_for_display(self): + # ordered dict is empty if products are not visible in e-com + product_1 = self.env["product.product"].search( + [("name", "=", "Test Smart Product")] + ) + groups = product_1._prepare_additional_attributes_for_display() + self.assertFalse(groups) + # ordered dict is exists if products are visible in e-com + self.product_1.write({"x_technical_description": "Fast processor"}) + self.attr_1.write({"e_com_visibility": True}) + groups = product_1._prepare_additional_attributes_for_display() + self.assertTrue(self.group_1 in groups) + self.assertTrue(self.attr_1 in groups[self.group_1]) + self.assertTrue(product_1 in groups[self.group_1][self.attr_1]) diff --git a/website_attribute_set/views/attribute_attribute_view.xml b/website_attribute_set/views/attribute_attribute_view.xml new file mode 100644 index 000000000..57c621cf5 --- /dev/null +++ b/website_attribute_set/views/attribute_attribute_view.xml @@ -0,0 +1,27 @@ + + + + attribute.attribute.form + attribute.attribute + + + + + + + + + + + + + + + + diff --git a/website_attribute_set/views/templates.xml b/website_attribute_set/views/templates.xml new file mode 100644 index 000000000..55371a8ae --- /dev/null +++ b/website_attribute_set/views/templates.xml @@ -0,0 +1,252 @@ + + + + + diff --git a/website_attribute_set/views/variant_templates.xml b/website_attribute_set/views/variant_templates.xml new file mode 100644 index 000000000..28ae9113c --- /dev/null +++ b/website_attribute_set/views/variant_templates.xml @@ -0,0 +1,54 @@ + + + + + diff --git a/website_attribute_set/views/website_sale_comparison_template.xml b/website_attribute_set/views/website_sale_comparison_template.xml new file mode 100644 index 000000000..cf0c421bf --- /dev/null +++ b/website_attribute_set/views/website_sale_comparison_template.xml @@ -0,0 +1,79 @@ + + + + + + +