Skip to content

[ADD] website_attribute_set: add a module to show custom attributes in website#202

Open
kobros-tech wants to merge 2 commits intoOCA:18.0from
kencove:18.0-add-website_attribute_set
Open

[ADD] website_attribute_set: add a module to show custom attributes in website#202
kobros-tech wants to merge 2 commits intoOCA:18.0from
kencove:18.0-add-website_attribute_set

Conversation

@kobros-tech
Copy link
Copy Markdown
Contributor

@kobros-tech kobros-tech commented Feb 24, 2025

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:

@kobros-tech kobros-tech force-pushed the 18.0-add-website_attribute_set branch 2 times, most recently from 4916642 to de1886c Compare February 25, 2025 00:43
@kobros-tech kobros-tech marked this pull request as draft February 25, 2025 00:46
@kobros-tech
Copy link
Copy Markdown
Contributor Author

kobros-tech commented Feb 25, 2025

  • Add a boolean field to choose whether we can show attribute in website or no
  • Create a method to return a record set specific for attributes of type select or multiselect to enable user to choose from
  • Update the method get_attributes that works for websited we shall make it filter the attributes for website only
  • Filter and Search functionality
  • Watch out assigning attributes their values and append them in hash arguments in URL
  • Add test cases to the module

@kobros-tech kobros-tech force-pushed the 18.0-add-website_attribute_set branch 2 times, most recently from 5d11aa0 to 8189fb9 Compare February 26, 2025 19:24
@kobros-tech
Copy link
Copy Markdown
Contributor Author

kobros-tech commented Feb 26, 2025

To show additional attributes in product page:

Screenshot from 2025-02-28 01-28-11

@kobros-tech
Copy link
Copy Markdown
Contributor Author

kobros-tech commented Feb 27, 2025

To show addtional attributes that can be filtered and searched:

Screenshot from 2025-03-02 13-28-33

@kobros-tech kobros-tech force-pushed the 18.0-add-website_attribute_set branch from ba36246 to 2e125eb Compare March 2, 2025 10:27
@kobros-tech
Copy link
Copy Markdown
Contributor Author

kobros-tech commented Mar 2, 2025

To filter results based on choosen attributes including the addtional ones:

Screenshot from 2025-03-02 13-29-06

@kobros-tech kobros-tech force-pushed the 18.0-add-website_attribute_set branch from 1007f1a to c5d3d1f Compare March 2, 2025 17:49
@kobros-tech
Copy link
Copy Markdown
Contributor Author

To search results based on choosen attributes including the addtional ones:

Screenshot from 2025-03-02 20-57-41

@kobros-tech kobros-tech marked this pull request as ready for review March 2, 2025 18:05
@kobros-tech kobros-tech changed the title [DEV][ADD] website_attribute_set: add a module to show custom attributes in website [ADD] website_attribute_set: add a module to show custom attributes in website Mar 2, 2025
@kobros-tech kobros-tech force-pushed the 18.0-add-website_attribute_set branch 3 times, most recently from b790f1f to 0177c34 Compare March 3, 2025 04:00
Copy link
Copy Markdown
Member

@mymage mymage left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested on Runbot an it seems not working inside product: attributes are always visible; on the list view it is ok.
Maybe I'm doing something wrong.
ed: I filled my lack, all goes well

@kobros-tech
Copy link
Copy Markdown
Contributor Author

@mymage

did you use pim before?

@mymage
Copy link
Copy Markdown
Member

mymage commented Mar 3, 2025

@mymage

did you use pim before?

No

@kobros-tech
Copy link
Copy Markdown
Contributor Author

@mymage
did you use pim before?

No

watch this video
https://youtu.be/36u13vFuzcc?si=6fsxxtlJJxZVdpmS

@kobros-tech
Copy link
Copy Markdown
Contributor Author

To show additional attributes in products comparison:

Screenshot from 2025-03-04 22-10-21

@mymage
Copy link
Copy Markdown
Member

mymage commented Mar 5, 2025

@mymage
did you use pim before?

No

watch this video https://youtu.be/36u13vFuzcc?si=6fsxxtlJJxZVdpmS

Thanks, now I see the option.

Copy link
Copy Markdown
Member

@mymage mymage left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Functional test LGTM

@kobros-tech kobros-tech force-pushed the 18.0-add-website_attribute_set branch from bfa83c1 to bd26740 Compare March 6, 2025 12:31
@kobros-tech
Copy link
Copy Markdown
Contributor Author

@rvalyi
@ovnicraft

can we review this new module?

@kobros-tech kobros-tech force-pushed the 18.0-add-website_attribute_set branch from bd26740 to d7c80aa Compare March 9, 2025 00:41
@rvalyi
Copy link
Copy Markdown
Member

rvalyi commented Mar 14, 2025

@rvalyi @ovnicraft

can we review this new module?

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

@kobros-tech kobros-tech force-pushed the 18.0-add-website_attribute_set branch 2 times, most recently from 2f2e259 to a417d18 Compare May 27, 2025 14:52

class WebsiteSale(main.WebsiteSale):
@route()
def shop(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you clarify, please?

Copy link
Copy Markdown
Contributor Author

@kobros-tech kobros-tech Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can I get an example or a snap image?

Copy link
Copy Markdown

@MiquelRForgeFlow MiquelRForgeFlow Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can I get an example or a snap image?

Selection_5254

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"
    />

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this update is related to pim I guess, not concerning this PR

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe a new IMP PR after pim module gets migrated and merged.

Copy link
Copy Markdown

@MiquelRForgeFlow MiquelRForgeFlow Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is related to website_attribute_set because public categories are defined in website_sale, and pim is also a dependency of website_attribute_set.

Comment on lines +20 to +321
# 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
Copy link
Copy Markdown

@MiquelRForgeFlow MiquelRForgeFlow Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

@MiquelRForgeFlow MiquelRForgeFlow Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean, if you prefer, I can make comments line by line...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about this too. Anyway, I will leave it to you the way you like.

Copy link
Copy Markdown

@MiquelRForgeFlow MiquelRForgeFlow Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, minimize/hide comments.

"depends": [
"attribute_set",
"product_attribute_set",
"pim",
Copy link
Copy Markdown

@MiquelRForgeFlow MiquelRForgeFlow Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can I get an example or a snap image?

Selection_5254

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"
    />

@kobros-tech kobros-tech force-pushed the 18.0-add-website_attribute_set branch 2 times, most recently from 42c44d3 to 02c0e04 Compare December 10, 2025 12:06
@kobros-tech
Copy link
Copy Markdown
Contributor Author

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")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return request.redirect("/web/login")
return request.redirect(f"/web/login?redirect={request.httprequest.path}")

Comment on lines +74 to +75
# anyalyze the url args to be used in filter and search
request_args = request.httprequest.args
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# 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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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)

Comment on lines +206 to +207
("product_ids.is_published", "=", True),
("visible_on_ecommerce", "=", True),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
("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),

Comment on lines +238 to +247
attributes = lazy(
lambda: ProductAttribute.search(
[
("product_tmpl_ids", "in", search_product.ids),
("visibility", "=", "visible"),
]
)
)
else:
attributes = lazy(lambda: ProductAttribute.browse(attributes_ids))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"additional_attrib_set": additional_attrib_set,
# START HOOK 2
"additional_attrib_set": additional_attrib_set,
# END HOOK 2

@kobros-tech
Copy link
Copy Markdown
Contributor Author

ok, anything else?

Copy link
Copy Markdown

@MiquelRForgeFlow MiquelRForgeFlow left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice

@MiquelRForgeFlow
Copy link
Copy Markdown

Please, resolve conflicts (and tests).

Copy link
Copy Markdown

@MiquelRForgeFlow MiquelRForgeFlow left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be better if the commits are squashed/reduced at minimum, plus a rebase, before merging this.

@dnplkndll dnplkndll force-pushed the 18.0-add-website_attribute_set branch from 372f585 to c32a257 Compare January 21, 2026 12:28
Comment on lines +2 to +4
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove

@MiquelRForgeFlow
Copy link
Copy Markdown

MiquelRForgeFlow commented Jan 21, 2026

Please, merge commits again 😅 and I will find someone to merge this PR.

@dnplkndll dnplkndll force-pushed the 18.0-add-website_attribute_set branch from 0b8cb5f to 12a94a3 Compare January 21, 2026 16:21
@JordiBForgeFlow
Copy link
Copy Markdown
Member

@rousseldenis can you merge this one please?

@MiquelRForgeFlow
Copy link
Copy Markdown

@rvalyi

@kobros-tech
Copy link
Copy Markdown
Contributor Author

@JordiBForgeFlow
@mymage
@rvalyi

Can we merge and celerbare, please?

@kobros-tech kobros-tech force-pushed the 18.0-add-website_attribute_set branch from 12a94a3 to 972e9fa Compare March 22, 2026 14:01
@JordiBForgeFlow
Copy link
Copy Markdown
Member

@MiquelRForgeFlow has been doing some improvements in v19 and will follow-up on this PR.

@kobros-tech
Copy link
Copy Markdown
Contributor Author

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

@kobros-tech kobros-tech force-pushed the 18.0-add-website_attribute_set branch 2 times, most recently from e8e7f63 to a80c51a Compare March 22, 2026 19:48
@kobros-tech kobros-tech force-pushed the 18.0-add-website_attribute_set branch from a80c51a to 09494df Compare March 22, 2026 20:19
@kobros-tech
Copy link
Copy Markdown
Contributor Author

@pedrobaeza
@JordiBForgeFlow

I did my best so these tests succeed here, but the 2 PRs need an urgent merge:

#242
#243

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants