[ADD] website_attribute_set: add a module to show custom attributes in website#202
[ADD] website_attribute_set: add a module to show custom attributes in website#202kobros-tech wants to merge 2 commits intoOCA:18.0from
Conversation
4916642 to
de1886c
Compare
|
5d11aa0 to
8189fb9
Compare
ba36246 to
2e125eb
Compare
1007f1a to
c5d3d1f
Compare
b790f1f to
0177c34
Compare
|
did you use pim before? |
No |
watch this video |
Thanks, now I see the option. |
bfa83c1 to
bd26740
Compare
|
can we review this new module? |
bd26740 to
d7c80aa
Compare
Hello @kobros-tech. As you see I'm making some efforts to help merging a few more basic odoo-pim PRs you made as I'm interested in that too. However I'm afraid, I'm not sure I'll be able to get as far as this website module in the time window you need, but I might try, To give you some context, I'm the guy who made the early odoo PIM prototype 15 years ago when the OCA was still incubating on Launchpad. I'm also the Akretion founder. Initially we made these modules to offer the same kind of product attributes flexibility you get with the Magento "EAV" model, but with a decent no-SQL design for performance. Then I also push and prototyped Shopinvader which is our open source e-commerce solution at Akretion for 10 years now. Indeed 10 years ago, the Odoo e-commerce was a big joke to say the least and Shopinvader allowed us to support the big customers that were using Magento initially. There would also be a lot to be told about Odoo security the way it was 10 years ago to give arbitrary ecommerce users permissions to try hacking into your ERP (extremly large attack surface). That being said, 10 years ago Odoo started to actively "canibalize" its initial beloved "partners" to please its investors and turn OpenERP into a license and SaaS business. In that context, we in Brazil had to advance what is just the most complex localization in the world like crazy to keep up with the Odoo marketing starting to sell Odoo as a turnkey ERP and not as an ERP framework anymore (OpenERP). I had to give up a bit on Shopinvader and the PIM modules to make miracles in OCA/l10n-brazil (the biggest OCA repo). As for now, the Odoo ecommerce starts to be decent and Shopinvader, while extremely scalable, has a much higher entry barrier. For the kind of companies, that are broke and are not technology aware, we usually deal with in Brazil, the native Odoo e-commerce seems finally like a viable option. So I'm extremely happy to see guys like you bringing these Shopinvader features to the native Odoo ecommerce stack. I also recently told @sebastienbeau I think it would be cool if Shopinvader could have a simple mode were it could simply reuse the Odoo cart and cookie system which seems finally scalable and decent so eventually more things could be shared between the two stacks and shopinvader could be just a set of extra modules you would add when you need psycopath scalability or want to design your shop frontend using the latest sexiest "full stack" framework, possibly with the AI tools popping all over the place. Also Postgres starts to compete a bit with Elastic and Elastic went to the dark side of the open source. With Odoo now starting to support hitting Postgres replicae for its website, the line with hitting the Elastic store is becoming blurred, so that's one more reason to reconsider using more of the Odoo ecommerce stack. cc @sbidoul @lmignon @simahawk @hparfr @pedrobaeza @JordiBForgeFlow |
2f2e259 to
a417d18
Compare
|
|
||
| class WebsiteSale(main.WebsiteSale): | ||
| @route() | ||
| def shop( |
There was a problem hiding this comment.
I think it's better to do a super call instead of method replace. If not, please put in separated comments which part is Odoo code and which part is added (# START HOOK 1, # END HOOK 1).
| "depends": [ | ||
| "attribute_set", | ||
| "product_attribute_set", | ||
| "pim", |
There was a problem hiding this comment.
As this module depends on pim, I would expect a modification of pim.main_menu_category by adding not only a submenu for product categories, but also a submenu for product public categories.
There was a problem hiding this comment.
Can you clarify, please?
There was a problem hiding this comment.
Can I get an example or a snap image?
There was a problem hiding this comment.
Can I get an example or a snap image?
This is done by adding in website_attribute_set the following:
<menuitem
id="menu_product_category"
name="Product Categories"
parent="pim.main_menu_category"
action="product.product_category_action_form"
sequence="10"
/>
<menuitem
id="menu_public_category"
name="eCommerce Categories"
parent="pim.main_menu_category"
action="website_sale.product_public_category_action"
sequence="20"
/>There was a problem hiding this comment.
this update is related to pim I guess, not concerning this PR
There was a problem hiding this comment.
maybe a new IMP PR after pim module gets migrated and merged.
There was a problem hiding this comment.
Is related to website_attribute_set because public categories are defined in website_sale, and pim is also a dependency of website_attribute_set.
| # START HOOK | ||
| @route() | ||
| def shop( | ||
| 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 | ||
|
|
||
| # anyalyze 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 | ||
|
|
||
| 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()) | ||
| pricelist = website.pricelist_id | ||
| 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) | ||
| website.invalidate_recordset(["pricelist_id"]) | ||
| pricelist = website.pricelist_id | ||
| 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( | ||
| attrib_set, options, post, search, website | ||
| ) | ||
|
|
||
| filter_by_price_enabled = website.is_view_active( | ||
| "website_sale.filter_products_price" | ||
| ) | ||
| if filter_by_price_enabled: | ||
| # TODO Find an alternative way to obtain | ||
| # the domain through the search metadata. | ||
| Product = request.env["product.template"].with_context(bin_size=True) | ||
| domain = self._get_shop_domain(search, category, attrib_values) | ||
|
|
||
| # This is ~4 times more efficient than a search | ||
| # for the cheapest and most expensive products | ||
| query = Product._where_calc(domain) | ||
| Product._apply_ir_rules(query, "read") | ||
| sql = query.select( | ||
| SQL( | ||
| "COALESCE(MIN(list_price), 0) * %(conversion_rate)s, " | ||
| "COALESCE(MAX(list_price), 0) * %(conversion_rate)s", | ||
| conversion_rate=conversion_rate, | ||
| ) | ||
| ) | ||
| available_min_price, available_max_price = request.env.execute_query(sql)[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( | ||
| expression.AND( | ||
| [ | ||
| [ | ||
| ("product_ids.is_published", "=", True), | ||
| ("visible_on_ecommerce", "=", 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] | ||
|
|
||
| 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 = lazy(lambda: 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, | ||
| "attrib_values": attrib_values, | ||
| "attrib_set": attrib_set, | ||
| "additional_attrib_set": additional_attrib_set, | ||
| "pager": pager, | ||
| "products": products, | ||
| "search_product": search_product, | ||
| "search_count": product_count, # common for all searchbox | ||
| "bins": lazy(lambda: 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: lazy( | ||
| lambda: 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_extra_shop_values(values, **post)) | ||
|
|
||
| return request.render("website_sale.products", values) | ||
|
|
||
| # END HOOK |
There was a problem hiding this comment.
| # START HOOK | |
| @route() | |
| def shop( | |
| 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 | |
| # anyalyze 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 | |
| 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()) | |
| pricelist = website.pricelist_id | |
| 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) | |
| website.invalidate_recordset(["pricelist_id"]) | |
| pricelist = website.pricelist_id | |
| 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( | |
| attrib_set, options, post, search, website | |
| ) | |
| filter_by_price_enabled = website.is_view_active( | |
| "website_sale.filter_products_price" | |
| ) | |
| if filter_by_price_enabled: | |
| # TODO Find an alternative way to obtain | |
| # the domain through the search metadata. | |
| Product = request.env["product.template"].with_context(bin_size=True) | |
| domain = self._get_shop_domain(search, category, attrib_values) | |
| # This is ~4 times more efficient than a search | |
| # for the cheapest and most expensive products | |
| query = Product._where_calc(domain) | |
| Product._apply_ir_rules(query, "read") | |
| sql = query.select( | |
| SQL( | |
| "COALESCE(MIN(list_price), 0) * %(conversion_rate)s, " | |
| "COALESCE(MAX(list_price), 0) * %(conversion_rate)s", | |
| conversion_rate=conversion_rate, | |
| ) | |
| ) | |
| available_min_price, available_max_price = request.env.execute_query(sql)[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( | |
| expression.AND( | |
| [ | |
| [ | |
| ("product_ids.is_published", "=", True), | |
| ("visible_on_ecommerce", "=", 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] | |
| 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 = lazy(lambda: 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, | |
| "attrib_values": attrib_values, | |
| "attrib_set": attrib_set, | |
| "additional_attrib_set": additional_attrib_set, | |
| "pager": pager, | |
| "products": products, | |
| "search_product": search_product, | |
| "search_count": product_count, # common for all searchbox | |
| "bins": lazy(lambda: 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: lazy( | |
| lambda: 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_extra_shop_values(values, **post)) | |
| return request.render("website_sale.products", values) | |
| # END HOOK | |
| @route() | |
| def shop( | |
| 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(f'/web/login?redirect={request.httprequest.path}') | |
| 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 | |
| # START HOOK 1 | |
| 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 | |
| # END HOOK 1 | |
| 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()) | |
| pricelist = website.pricelist_id | |
| 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) | |
| website.invalidate_recordset(["pricelist_id"]) | |
| pricelist = website.pricelist_id | |
| 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( | |
| attrib_set, options, post, search, website | |
| ) | |
| filter_by_price_enabled = website.is_view_active( | |
| "website_sale.filter_products_price" | |
| ) | |
| if filter_by_price_enabled: | |
| # TODO Find an alternative way to obtain | |
| # the domain through the search metadata. | |
| Product = request.env["product.template"].with_context(bin_size=True) | |
| search_term = fuzzy_search_term if fuzzy_search_term else search | |
| domain = self._get_shop_domain(search_term, category, attrib_values) | |
| # This is ~4 times more efficient than a search | |
| # for the cheapest and most expensive products | |
| query = Product._where_calc(domain) | |
| Product._apply_ir_rules(query, "read") | |
| sql = query.select( | |
| SQL( | |
| "COALESCE(MIN(list_price), 0) * %(conversion_rate)s, " | |
| "COALESCE(MAX(list_price), 0) * %(conversion_rate)s", | |
| conversion_rate=conversion_rate, | |
| ) | |
| ) | |
| available_min_price, available_max_price = request.env.execute_query(sql)[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( | |
| expression.AND( | |
| [ | |
| [ | |
| ("visible_on_ecommerce", "=", True), | |
| "|", | |
| ("product_template_ids.is_published", "=", True), | |
| ("product_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] | |
| ProductAttribute = request.env["product.attribute"] | |
| if products: | |
| # get all products without limit | |
| attributes_grouped = request.env[ | |
| "product.template.attribute.line" | |
| ]._read_group( | |
| domain=[ | |
| ("product_tmpl_id", "in", search_product.ids), | |
| ("attribute_id.visibility", "=", "visible"), | |
| ], | |
| groupby=["attribute_id"], | |
| ) | |
| attributes_ids = [ | |
| attribute.id for attribute, *aggregates in attributes_grouped | |
| ] | |
| 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 = lazy(lambda: 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, | |
| "attrib_values": attrib_values, | |
| "attrib_set": attrib_set, | |
| # START HOOK 2 | |
| "additional_attrib_set": additional_attrib_set, | |
| # END HOOK 2 | |
| "pager": pager, | |
| "products": products, | |
| "search_product": search_product, | |
| "search_count": product_count, # common for all searchbox | |
| "bins": lazy(lambda: 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: lazy( | |
| lambda: 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_extra_shop_values(values, **post)) | |
| return request.render("website_sale.products", values) |
I included last odoo changes.
There was a problem hiding this comment.
The entire method is a hook, I override the original odoo method, what did you want?
I can not select a part of it to say this is an override, in fact we use pre-commit that displaces the code and organises it.
odoo do not use pre-commit, let it be like this as an indicator not to change any part of this method as it will break odoo itself unless you see and compare and know what you are doing.
in fact name Hook, can be better Patch
There was a problem hiding this comment.
Well, it's a shame github doesn't show the diff properly. But if you copy the full method I wrote, and you paste it in your code, in your IDE you will see the changes I propose.
There was a problem hiding this comment.
I mean, if you prefer, I can make comments line by line...
There was a problem hiding this comment.
I thought about this too. Anyway, I will leave it to you the way you like.
There was a problem hiding this comment.
Ok, minimize/hide comments.
| "depends": [ | ||
| "attribute_set", | ||
| "product_attribute_set", | ||
| "pim", |
There was a problem hiding this comment.
Can I get an example or a snap image?
This is done by adding in website_attribute_set the following:
<menuitem
id="menu_product_category"
name="Product Categories"
parent="pim.main_menu_category"
action="product.product_category_action_form"
sequence="10"
/>
<menuitem
id="menu_public_category"
name="eCommerce Categories"
parent="pim.main_menu_category"
action="website_sale.product_public_category_action"
sequence="20"
/>42c44d3 to
02c0e04
Compare
|
These tests in OCA are funny, locally tests run well but here there is an objection against an intended work, and I seertRaises indeed. Any suggestions to ignore that objection? or should I remove this part of the test? |
| **post, | ||
| ): | ||
| if not request.website.has_ecommerce_access(): | ||
| return request.redirect("/web/login") |
There was a problem hiding this comment.
| return request.redirect("/web/login") | |
| return request.redirect(f"/web/login?redirect={request.httprequest.path}") |
| # anyalyze the url args to be used in filter and search | ||
| request_args = request.httprequest.args |
There was a problem hiding this comment.
| # anyalyze the url args to be used in filter and search | |
| request_args = request.httprequest.args | |
| # START HOOK 1 |
| ) | ||
| post["additional_attrib_set"] = additional_attrib_set | ||
| post["additional_attrib_values"] = additional_attrib_values | ||
|
|
There was a problem hiding this comment.
| # END HOOK 1 | |
| # TODO Find an alternative way to obtain | ||
| # the domain through the search metadata. | ||
| Product = request.env["product.template"].with_context(bin_size=True) | ||
| domain = self._get_shop_domain(search, category, attrib_values) |
There was a problem hiding this comment.
| domain = self._get_shop_domain(search, category, attrib_values) | |
| search_term = fuzzy_search_term if fuzzy_search_term else search | |
| domain = self._get_shop_domain(search_term, category, attrib_values) |
| ("product_ids.is_published", "=", True), | ||
| ("visible_on_ecommerce", "=", True), |
There was a problem hiding this comment.
| ("product_ids.is_published", "=", True), | |
| ("visible_on_ecommerce", "=", True), | |
| ("visible_on_ecommerce", "=", True), | |
| "|", | |
| ("product_template_ids.is_published", "=", True), | |
| ("product_product_ids.is_published", "=", True), |
| attributes = lazy( | ||
| lambda: ProductAttribute.search( | ||
| [ | ||
| ("product_tmpl_ids", "in", search_product.ids), | ||
| ("visibility", "=", "visible"), | ||
| ] | ||
| ) | ||
| ) | ||
| else: | ||
| attributes = lazy(lambda: ProductAttribute.browse(attributes_ids)) |
There was a problem hiding this comment.
| attributes = lazy( | |
| lambda: ProductAttribute.search( | |
| [ | |
| ("product_tmpl_ids", "in", search_product.ids), | |
| ("visibility", "=", "visible"), | |
| ] | |
| ) | |
| ) | |
| else: | |
| attributes = lazy(lambda: ProductAttribute.browse(attributes_ids)) | |
| attributes_grouped = request.env[ | |
| "product.template.attribute.line" | |
| ]._read_group( | |
| domain=[ | |
| ("product_tmpl_id", "in", search_product.ids), | |
| ("attribute_id.visibility", "=", "visible"), | |
| ], | |
| groupby=["attribute_id"], | |
| ) | |
| attributes_ids = [ | |
| attribute.id for attribute, *aggregates in attributes_grouped | |
| ] | |
| attributes = lazy(lambda: ProductAttribute.browse(attributes_ids)) |
| "category": category, | ||
| "attrib_values": attrib_values, | ||
| "attrib_set": attrib_set, | ||
| "additional_attrib_set": additional_attrib_set, |
There was a problem hiding this comment.
| "additional_attrib_set": additional_attrib_set, | |
| # START HOOK 2 | |
| "additional_attrib_set": additional_attrib_set, | |
| # END HOOK 2 |
|
ok, anything else? |
|
Please, resolve conflicts (and tests). |
MiquelRForgeFlow
left a comment
There was a problem hiding this comment.
It would be better if the commits are squashed/reduced at minimum, plus a rebase, before merging this.
372f585 to
c32a257
Compare
test-requirements.txt
Outdated
| odoo-addon-attribute_set @ git+https://github.com/OCA/odoo-pim.git@refs/pull/192/head#subdirectory=attribute_set | ||
| odoo-addon-product_attribute_set @ git+https://github.com/OCA/odoo-pim.git@refs/pull/196/head#subdirectory=product_attribute_set | ||
| odoo-addon-pim @ git+https://github.com/OCA/odoo-pim.git@refs/pull/197/head#subdirectory=pim |
|
Please, merge commits again 😅 and I will find someone to merge this PR. |
0b8cb5f to
12a94a3
Compare
|
@rousseldenis can you merge this one please? |
|
@JordiBForgeFlow Can we merge and celerbare, please? |
12a94a3 to
972e9fa
Compare
|
@MiquelRForgeFlow has been doing some improvements in v19 and will follow-up on this PR. |
|
all right, I found an issue in the tests for attribute_set merged module it affects all tests for all open PRs and other merged modules tests. going to fix it in a new PR |
e8e7f63 to
a80c51a
Compare
a80c51a to
09494df
Compare
|
I did my best so these tests succeed here, but the 2 PRs need an urgent merge: |





This new module is based on pim module and attribute_set and product_attribute_set
By default it is based on website, website_sale as we will display our OCA additional attributes in e-commerce website app.
We plan to optimize it so user can filter, search and choose products based on OCA attributes there.
Dependencies: