You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
468 lines
20 KiB
468 lines
20 KiB
# -*- coding: utf-8 -*-
|
|
#############################################################################
|
|
#
|
|
# Cybrosys Technologies Pvt. Ltd.
|
|
#
|
|
# Copyright (C) 2024-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
|
|
# Author: Akhil Ashok (<https://www.cybrosys.com>)
|
|
#
|
|
# You can modify it under the terms of the GNU LESSER
|
|
# GENERAL PUBLIC LICENSE (LGPL v3), Version 3.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU LESSER GENERAL PUBLIC LICENSE (LGPL v3) for more details.
|
|
#
|
|
# You should have received a copy of the GNU LESSER GENERAL PUBLIC LICENSE
|
|
# (LGPL v3) along with this program.
|
|
# If not, see <http://www.gnu.org/licenses/>.
|
|
#
|
|
#############################################################################
|
|
import logging
|
|
from datetime import datetime
|
|
from werkzeug.exceptions import NotFound
|
|
from odoo import fields, http, tools
|
|
from odoo.http import request
|
|
from odoo.addons.http_routing.models.ir_http import slug
|
|
from odoo.addons.payment.controllers import portal as payment_portal
|
|
from odoo.addons.website.controllers.main import QueryURL
|
|
from odoo.addons.website.models.ir_http import sitemap_qs2dom
|
|
from odoo.tools import lazy
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class TableCompute(object):
|
|
"""
|
|
Class for computing the arrangement of products on a grid.
|
|
"""
|
|
def __init__(self):
|
|
self.table = {}
|
|
|
|
def _check_place(self, posx, posy, sizex, sizey, ppr):
|
|
"""
|
|
Check if a specified rectangular area is available in the table.
|
|
|
|
Parameters:
|
|
- posx (int): The starting x-coordinate of the area.
|
|
- posy (int): The starting y-coordinate of the area.
|
|
- sizex (int): The width of the area.
|
|
- sizey (int): The height of the area.
|
|
- ppr (int): Maximum allowed x-coordinate in the table.
|
|
|
|
Returns:
|
|
bool: True if the area is available, False otherwise.
|
|
|
|
The function iterates over the specified area and checks if each cell is
|
|
within the bounds of the table and if the cell is unoccupied. It returns
|
|
True if the entire area is available; otherwise, it returns False.
|
|
"""
|
|
res = True
|
|
for y in range(sizey):
|
|
for x in range(sizex):
|
|
if posx + x >= ppr:
|
|
res = False
|
|
break
|
|
row = self.table.setdefault(posy + y, {})
|
|
if row.setdefault(posx + x) is not None:
|
|
res = False
|
|
break
|
|
for x in range(ppr):
|
|
self.table[posy + y].setdefault(x, None)
|
|
return res
|
|
|
|
def process(self, products, ppg=20, ppr=4):
|
|
"""
|
|
Arrange a list of products on a grid and format the result for HTML
|
|
representation.
|
|
|
|
Parameters:
|
|
- products (list): A list of product objects to be arranged on the
|
|
grid.
|
|
- ppg (int): The maximum number of products per grid page
|
|
(default is 20).
|
|
- ppr (int): The maximum number of products per row in the grid
|
|
(default is 4).
|
|
|
|
Returns:
|
|
list: A formatted representation of the arranged products suitable
|
|
for HTML rendering.The result is a list of rows, where each row is
|
|
a list of dictionaries representing products and their positions on
|
|
the grid.
|
|
|
|
The function iterates over the list of products and computes their
|
|
positions on the grid.It uses a heuristic algorithm to determine the
|
|
position of each product, taking into account the dimensions of the
|
|
products, the maximum products per page, and the maximum products per
|
|
row.The result is formatted as a list of rows, each containing
|
|
dictionaries representing the products and their positions on the grid.
|
|
"""
|
|
# Compute products positions on the grid
|
|
minpos = 0
|
|
index = 0
|
|
maxy = 0
|
|
x = 0
|
|
for p in products:
|
|
x = min(max(p.website_size_x, 1), ppr)
|
|
y = min(max(p.website_size_y, 1), ppr)
|
|
if index >= ppg:
|
|
x = y = 1
|
|
pos = minpos
|
|
while not self._check_place(pos % ppr, pos // ppr, x, y, ppr):
|
|
pos += 1
|
|
# if 21st products (index 20) and the last line is full (ppr products in it), break
|
|
# (pos + 1.0) / ppr is the line where the product would be inserted
|
|
# maxy is the number of existing lines
|
|
# + 1.0 is because pos begins at 0, thus pos 20 is actually the 21st block
|
|
# and to force python to not round the division operation
|
|
if index >= ppg and ((pos + 1.0) // ppr) > maxy:
|
|
break
|
|
if x == 1 and y == 1: # simple heuristic for CPU optimization
|
|
minpos = pos // ppr
|
|
|
|
for y2 in range(y):
|
|
for x2 in range(x):
|
|
self.table[(pos // ppr) + y2][(pos % ppr) + x2] = False
|
|
self.table[pos // ppr][pos % ppr] = {
|
|
'product': p, 'x': x, 'y': y,
|
|
'ribbon': p.sudo().website_ribbon_id,
|
|
}
|
|
if index <= ppg:
|
|
maxy = max(maxy, y + (pos // ppr))
|
|
index += 1
|
|
# Format table according to HTML needs
|
|
rows = sorted(self.table.items())
|
|
rows = [r[1] for r in rows]
|
|
for col in range(len(rows)):
|
|
cols = sorted(rows[col].items())
|
|
x += len(cols)
|
|
rows[col] = [r[1] for r in cols if r[1]]
|
|
return rows
|
|
|
|
|
|
class WebsiteSale(payment_portal.PaymentPortal):
|
|
|
|
def sitemap_shop(env, rule, qs):
|
|
"""
|
|
Generate sitemap entries for the Odoo eCommerce shop.
|
|
"""
|
|
if not qs or qs.lower() in '/shop':
|
|
yield {'loc': '/shop'}
|
|
|
|
Category = env['product.public.category']
|
|
dom = sitemap_qs2dom(qs, '/shop/category', Category._rec_name)
|
|
dom += env['website'].get_current_website().website_domain()
|
|
for cat in Category.search(dom):
|
|
loc = '/shop/category/%s' % slug(cat)
|
|
if not qs or qs.lower() in loc:
|
|
yield {'loc': loc}
|
|
|
|
@http.route([
|
|
'/shop',
|
|
'/shop/page/<int:page>',
|
|
'/shop/category/<model("product.public.category"):category>',
|
|
'/shop/category/<model("product.public.category"):category>/'
|
|
'page/<int:page>',
|
|
'''/shop/brand/<model("product.brand"):brand>''',
|
|
], type='http', auth="public", website=True, sitemap=sitemap_shop)
|
|
def shop(self, page=0, category=None, search='', min_price=0.0,
|
|
max_price=0.0,
|
|
ppg=False, brand=None, **post):
|
|
"""
|
|
Render the eCommerce shop page.
|
|
|
|
This function handles the rendering of the eCommerce shop page based on
|
|
the provided parameters.
|
|
It retrieves and filters products, applies pricing rules, and prepares
|
|
the data for rendering.
|
|
"""
|
|
add_qty = int(post.get('add_qty', 1))
|
|
try:
|
|
min_price = float(min_price)
|
|
except ValueError:
|
|
min_price = 0
|
|
try:
|
|
max_price = float(max_price)
|
|
except ValueError:
|
|
max_price = 0
|
|
compute_brand = brand
|
|
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
|
|
product_brand = request.env['product.brand']
|
|
if not brand:
|
|
brand = product_brand
|
|
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
|
|
|
|
request_args = request.httprequest.args
|
|
attrib_list = request_args.getlist('attrib')
|
|
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}
|
|
|
|
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.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
|
|
if attrib_list:
|
|
post['attrib'] = attrib_list
|
|
|
|
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')
|
|
from_clause, where_clause, where_params = query.get_sql()
|
|
query = f"""
|
|
SELECT COALESCE(MIN(list_price), 0) * {conversion_rate}, COALESCE(MAX(list_price), 0) * {conversion_rate}
|
|
FROM {from_clause}
|
|
WHERE {where_clause}
|
|
"""
|
|
request.env.cr.execute(query, where_params)
|
|
available_min_price, available_max_price = request.env.cr.fetchone()
|
|
|
|
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
|
|
|
|
if filter_by_tags_enabled:
|
|
if (
|
|
search_product.product_tag_ids
|
|
or search_product.product_variant_ids.additional_product_tag_ids
|
|
):
|
|
ProductTag = request.env['product.tag']
|
|
all_tags = ProductTag.search(
|
|
[('product_ids.is_published', '=', True),
|
|
('visible_on_ecommerce', '=', True)]
|
|
+ website_domain
|
|
)
|
|
else:
|
|
all_tags = []
|
|
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/%s" % slug(category)
|
|
|
|
pager = website.pager(url=url, total=product_count, page=page, step=ppg,
|
|
scope=7, 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
|
|
|
|
# Try to fetch geoip based fpos or fallback on partner one
|
|
fiscal_position_sudo = website.fiscal_position_id.sudo()
|
|
products_prices = lazy(
|
|
lambda: products._get_sales_prices(pricelist, fiscal_position_sudo))
|
|
products_prices_brand = lazy(
|
|
lambda: search_product._get_sales_prices(pricelist, fiscal_position_sudo))
|
|
product_brand = request.env['product.brand'].search([])
|
|
if compute_brand:
|
|
products_brand = request.env['product.template'].search(
|
|
['&', '&', ('brand_id', '=', brand.id), ('sale_ok', '=', True),
|
|
('is_published', '=', True)])
|
|
product_brand_count = len(products_brand)
|
|
pager_brand = request.website.pager(url=url,
|
|
total=product_brand_count,
|
|
page=page, step=ppg, scope=7,
|
|
url_args=post)
|
|
values = {
|
|
'search': fuzzy_search_term or search,
|
|
'original_search': fuzzy_search_term and search,
|
|
'order': post.get('order', ''),
|
|
'category': category,
|
|
'brand': brand,
|
|
'attrib_values': attrib_values,
|
|
'attrib_set': attrib_set,
|
|
'pager': pager_brand,
|
|
'pricelist': pricelist,
|
|
'fiscal_position': fiscal_position_sudo,
|
|
'add_qty': add_qty,
|
|
'products': products_brand,
|
|
'search_product': search_product,
|
|
'search_count': product_brand_count, # common for all searchbox
|
|
'bins': lazy(
|
|
lambda: TableCompute().process(products_brand, ppg, ppr)),
|
|
'ppg': ppg,
|
|
'ppr': ppr,
|
|
'categories': categs,
|
|
'attributes': attributes,
|
|
'keep': keep,
|
|
'search_categories_ids': search_categories.ids,
|
|
'layout_mode': layout_mode,
|
|
'brands':product_brand,
|
|
'products_prices': products_prices,
|
|
'get_product_prices': lambda product: lazy(
|
|
lambda: products_prices_brand[product.id]),
|
|
'float_round': tools.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'] = tools.float_round(
|
|
available_min_price,
|
|
2)
|
|
values['available_max_price'] = tools.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)
|
|
else:
|
|
values = {
|
|
'brand': brand,
|
|
'search': search,
|
|
'category': category,
|
|
'original_search': fuzzy_search_term and search,
|
|
'order': post.get('order', ''),
|
|
'attrib_values': attrib_values,
|
|
'attrib_set': attrib_set,
|
|
'pager': pager,
|
|
'pricelist': pricelist,
|
|
'add_qty': add_qty,
|
|
'products': products,
|
|
'search_count': product_count, # common for all searchbox
|
|
'bins': lazy(
|
|
lambda: TableCompute().process(products, ppg, ppr)),
|
|
'ppg': ppg,
|
|
'ppr': ppr,
|
|
'categories': categs,
|
|
'attributes': attributes,
|
|
'keep': keep,
|
|
'search_categories_ids': search_categories.ids,
|
|
'layout_mode': layout_mode,
|
|
'brands': product_brand,
|
|
'products_prices': products_prices,
|
|
'get_product_prices': lambda product: lazy(
|
|
lambda: products_prices[product.id]),
|
|
'float_round': tools.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'] = tools.float_round(
|
|
available_min_price,
|
|
2)
|
|
values['available_max_price'] = tools.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)
|
|
|