mirror of
https://github.com/bringout/oca-ocb-sale.git
synced 2026-04-26 16:52:03 +02:00
539 lines
24 KiB
Python
539 lines
24 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from werkzeug.exceptions import NotFound
|
|
|
|
from odoo import fields
|
|
from odoo.exceptions import UserError
|
|
from odoo.http import request, route
|
|
from odoo.tools import consteq
|
|
from odoo.tools.image import image_data_uri
|
|
from odoo.tools.translate import _
|
|
|
|
from odoo.addons.payment import utils as payment_utils
|
|
from odoo.addons.payment.controllers.portal import PaymentPortal
|
|
from odoo.addons.sale.controllers.portal import CustomerPortal
|
|
from odoo.addons.website_sale.controllers.main import WebsiteSale
|
|
|
|
|
|
class Cart(PaymentPortal):
|
|
|
|
@route(route='/shop/cart', type='http', auth='public', website=True, sitemap=False)
|
|
def cart(self, id=None, access_token=None, revive_method='', **post):
|
|
""" Display the cart page.
|
|
|
|
This route is responsible for the main cart management and abandoned cart revival logic.
|
|
|
|
:param str id: The abandoned cart's id.
|
|
:param str access_token: The abandoned cart's access token.
|
|
:param str revive_method: The revival method for abandoned carts. Can be 'merge' or 'squash'.
|
|
:return: The rendered cart page.
|
|
:rtype: str
|
|
"""
|
|
if not request.website.has_ecommerce_access():
|
|
return request.redirect('/web/login')
|
|
|
|
order_sudo = request.cart
|
|
|
|
values = {}
|
|
if id and access_token:
|
|
abandoned_order = request.env['sale.order'].sudo().browse(int(id)).exists()
|
|
if not abandoned_order or not consteq(abandoned_order.access_token, access_token): # wrong token (or SO has been deleted)
|
|
raise NotFound()
|
|
if abandoned_order.state != 'draft': # abandoned cart already finished
|
|
values.update({'abandoned_proceed': True})
|
|
elif revive_method == 'squash' or (revive_method == 'merge' and not request.session.get('sale_order_id')): # restore old cart or merge with unexistant
|
|
request.session['sale_order_id'] = abandoned_order.id
|
|
return request.redirect('/shop/cart')
|
|
elif revive_method == 'merge':
|
|
abandoned_order.order_line.write({'order_id': request.session['sale_order_id']})
|
|
abandoned_order.action_cancel()
|
|
elif abandoned_order.id != request.session.get('sale_order_id'): # abandoned cart found, user have to choose what to do
|
|
values.update({'id': abandoned_order.id, 'access_token': abandoned_order.access_token})
|
|
|
|
values.update({
|
|
'website_sale_order': order_sudo,
|
|
'date': fields.Date.today(),
|
|
'suggested_products': [],
|
|
})
|
|
if order_sudo:
|
|
order_sudo.order_line.filtered(lambda sol: sol.product_id and not sol.product_id.active).unlink()
|
|
values['suggested_products'] = order_sudo._cart_accessories()
|
|
values.update(self._get_express_shop_payment_values(order_sudo))
|
|
|
|
values.update(request.website._get_checkout_step_values())
|
|
values.update(self._cart_values(**post))
|
|
values.update(self._prepare_order_history())
|
|
return request.render('website_sale.cart', values)
|
|
|
|
def _cart_values(self, **post):
|
|
"""
|
|
This method is a hook to pass additional values when rendering the 'website_sale.cart' template (e.g. add
|
|
a flag to trigger a style variation)
|
|
"""
|
|
return {}
|
|
|
|
@route(
|
|
route='/shop/cart/add',
|
|
type='jsonrpc',
|
|
auth='public',
|
|
methods=['POST'],
|
|
website=True,
|
|
sitemap=False
|
|
)
|
|
def add_to_cart(
|
|
self,
|
|
product_template_id,
|
|
product_id,
|
|
quantity=1.0,
|
|
uom_id=None,
|
|
product_custom_attribute_values=None,
|
|
no_variant_attribute_value_ids=None,
|
|
linked_products=None,
|
|
**kwargs
|
|
):
|
|
""" Adds a product to the shopping cart.
|
|
|
|
:param int product_template_id: The product to add to cart, as a
|
|
`product.template` id.
|
|
:param int product_id: The product to add to cart, as a
|
|
`product.product` id.
|
|
:param int quantity: The quantity to add to the cart.
|
|
:param list[dict] product_custom_attribute_values: A list of objects representing custom
|
|
attribute values for the product. Each object contains:
|
|
- `custom_product_template_attribute_value_id`: The custom attribute's id;
|
|
- `custom_value`: The custom attribute's value.
|
|
:param dict no_variant_attribute_value_ids: The selected non-stored attribute(s), as a list
|
|
of `product.template.attribute.value` ids.
|
|
:param list linked_products: A list of objects representing additional products linked to
|
|
the product added to the cart. Can be combo item or optional products.
|
|
:param dict kwargs: Optional data. This parameter is not used here.
|
|
:return: The values
|
|
:rtype: dict
|
|
"""
|
|
order_sudo = request.cart or request.website._create_cart()
|
|
quantity = int(quantity) # Do not allow float values in ecommerce by default
|
|
|
|
product = request.env['product.product'].browse(product_id).exists()
|
|
if not product or not product._is_add_to_cart_allowed():
|
|
raise UserError(_(
|
|
"The given product does not exist therefore it cannot be added to cart."
|
|
))
|
|
|
|
added_qty_per_line = {}
|
|
values = order_sudo.with_context(skip_cart_verification=True)._cart_add(
|
|
product_id=product_id,
|
|
quantity=quantity,
|
|
uom_id=uom_id,
|
|
product_custom_attribute_values=product_custom_attribute_values,
|
|
no_variant_attribute_value_ids=no_variant_attribute_value_ids,
|
|
**kwargs,
|
|
)
|
|
line_ids = {product_template_id: values['line_id']}
|
|
added_qty_per_line[values['line_id']] = values['added_qty']
|
|
is_combo = product.type == 'combo'
|
|
updated_line = (
|
|
values['line_id']
|
|
and order_sudo.order_line.filtered(lambda line: line.id == values['line_id'])
|
|
) or order_sudo.env['sale.order.line']
|
|
|
|
if linked_products and values['line_id']:
|
|
for product_data in linked_products:
|
|
product_sudo = request.env['product.product'].sudo().browse(
|
|
product_data['product_id']
|
|
).exists()
|
|
if product_data['quantity'] and (
|
|
not product_sudo
|
|
or (
|
|
not product_sudo._is_add_to_cart_allowed()
|
|
# For combos, the validity of the given product will be checked
|
|
# through the SOline constraints (_check_combo_item_id)
|
|
and not product_data.get('combo_item_id')
|
|
)
|
|
):
|
|
raise UserError(_(
|
|
"The given product does not exist therefore it cannot be added to cart."
|
|
))
|
|
|
|
product_values = order_sudo.with_context(skip_cart_verification=True)._cart_add(
|
|
product_id=product_data['product_id'],
|
|
quantity=product_data['quantity'],
|
|
uom_id=product_data.get('uom_id'),
|
|
product_custom_attribute_values=product_data['product_custom_attribute_values'],
|
|
no_variant_attribute_value_ids=[
|
|
int(value_id) for value_id in product_data['no_variant_attribute_value_ids']
|
|
],
|
|
# Using `line_ids[...]` instead of `line_ids.get(...)` ensures that this throws
|
|
# if an optional product contains bad data.
|
|
linked_line_id=line_ids[product_data['parent_product_template_id']],
|
|
**self._get_additional_cart_update_values(product_data),
|
|
**kwargs,
|
|
)
|
|
if is_combo and not product_values.get('quantity'):
|
|
# Early return when one of the combo products if fully unavailable
|
|
# Delete main combo line (and existing children in cascade)
|
|
updated_line.unlink()
|
|
# Return empty notification since cart update is considered as failed
|
|
return {
|
|
'cart_quantity': order_sudo.cart_quantity,
|
|
'notification_info': {
|
|
'warning': product_values.get('warning', ''),
|
|
},
|
|
'quantity': 0,
|
|
'tracking_info': [],
|
|
}
|
|
|
|
line_ids[product_data['product_template_id']] = product_values['line_id']
|
|
added_qty_per_line[product_values['line_id']] = product_values['added_qty']
|
|
|
|
warning = values.pop('warning', '')
|
|
if is_combo and order_sudo._check_combo_quantities(updated_line):
|
|
# If quantities were modified through `_check_combo_quantities`, the added qty per line
|
|
# must be adapted accordingly, and the returned warning should be the final one saved
|
|
# on the combo line.
|
|
added_qty_per_line = {
|
|
line.id: updated_line.product_uom_qty
|
|
for line in (updated_line + updated_line.linked_line_ids)
|
|
}
|
|
warning = updated_line.shop_warning
|
|
values['quantity'] = updated_line.product_uom_qty
|
|
|
|
# Recompute delivery prices & other cart stuff (loyalty rewards)
|
|
order_sudo._verify_cart_after_update()
|
|
|
|
# The validity of a combo product line can only be checked after creating all of its combo
|
|
# item lines.
|
|
main_product_line = request.env['sale.order.line'].browse(values['line_id'])
|
|
if main_product_line.product_type == 'combo':
|
|
main_product_line._check_validity()
|
|
|
|
positive_added_qty_per_line = {
|
|
line_id: qty for line_id, qty in added_qty_per_line.items() if qty > 0
|
|
}
|
|
|
|
return {
|
|
'cart_quantity': order_sudo.cart_quantity,
|
|
'notification_info': {
|
|
**self._get_cart_notification_information(
|
|
order_sudo, positive_added_qty_per_line
|
|
),
|
|
'warning': warning,
|
|
},
|
|
'quantity': values.pop('quantity', 0),
|
|
'tracking_info': self._get_tracking_information(order_sudo, line_ids.values()),
|
|
}
|
|
|
|
@route(
|
|
route='/shop/cart/quick_add', type='jsonrpc', auth='user', methods=['POST'], website=True
|
|
)
|
|
def quick_add(self, product_template_id, product_id, quantity=1.0, **kwargs):
|
|
values = self.add_to_cart(product_template_id, product_id, quantity=quantity, **kwargs)
|
|
|
|
IrUiView = request.env['ir.ui.view']
|
|
order_sudo = request.cart
|
|
values['website_sale.cart_lines'] = IrUiView._render_template(
|
|
'website_sale.cart_lines', {
|
|
'website_sale_order': order_sudo,
|
|
'date': fields.Date.today(),
|
|
'suggested_products': order_sudo._cart_accessories(),
|
|
}
|
|
)
|
|
values['website_sale.shorter_cart_summary'] = IrUiView._render_template(
|
|
'website_sale.shorter_cart_summary', {
|
|
'website_sale_order': order_sudo,
|
|
'show_shorter_cart_summary': True,
|
|
**self._get_express_shop_payment_values(order_sudo),
|
|
**request.website._get_checkout_step_values(),
|
|
}
|
|
)
|
|
values['website_sale.quick_reorder_history'] = IrUiView._render_template(
|
|
'website_sale.quick_reorder_history', {
|
|
'website_sale_order': order_sudo,
|
|
**self._prepare_order_history(),
|
|
}
|
|
)
|
|
values['cart_ready'] = order_sudo._is_cart_ready()
|
|
return values
|
|
|
|
def _get_express_shop_payment_values(self, order, **kwargs):
|
|
payment_form_values = CustomerPortal._get_payment_values(
|
|
self, order, website_id=request.website.id, is_express_checkout=True
|
|
)
|
|
payment_form_values.update({
|
|
'payment_access_token': payment_form_values.pop('access_token'), # Rename the key.
|
|
# Do not include delivery related lines
|
|
'minor_amount': payment_utils.to_minor_currency_units(
|
|
order._get_amount_total_excluding_delivery(), order.currency_id
|
|
),
|
|
'merchant_name': request.website.name,
|
|
'transaction_route': f'/shop/payment/transaction/{order.id}',
|
|
'express_checkout_route': WebsiteSale._express_checkout_route,
|
|
'landing_route': '/shop/payment/validate',
|
|
'payment_method_unknown_id': request.env.ref('payment.payment_method_unknown').id,
|
|
'shipping_info_required': order._has_deliverable_products(),
|
|
# Todo: remove in master
|
|
'delivery_amount': payment_utils.to_minor_currency_units(
|
|
order.amount_total - order._compute_amount_total_without_delivery(),
|
|
order.currency_id,
|
|
),
|
|
'shipping_address_update_route': WebsiteSale._express_checkout_delivery_route,
|
|
})
|
|
if request.website.is_public_user():
|
|
payment_form_values['partner_id'] = -1
|
|
return payment_form_values
|
|
|
|
@route(
|
|
route='/shop/cart/update',
|
|
type='jsonrpc',
|
|
auth='public',
|
|
methods=['POST'],
|
|
website=True,
|
|
sitemap=False
|
|
)
|
|
def update_cart(self, line_id, quantity, product_id=None, **kwargs):
|
|
"""Update the quantity of a specific line of the current cart.
|
|
|
|
:param int line_id: line to update, as a `sale.order.line` id.
|
|
:param float quantity: new line quantity.
|
|
0 or negative numbers will only delete the line, the ecommerce
|
|
doesn't work with negative numbers.
|
|
:param int|None product_id: product_id of the edited line, only used when line_id
|
|
is falsy
|
|
:params dict kwargs: additional parameters given to _cart_update_line_quantity calls.
|
|
"""
|
|
order_sudo = request.cart
|
|
quantity = int(quantity) # Do not allow float values in ecommerce by default
|
|
IrUiView = request.env['ir.ui.view']
|
|
|
|
# This method must be only called from the cart page BUT in some advanced logic
|
|
# eg. website_sale_loyalty, a cart line could be a temporary record without id.
|
|
# In this case, the line_id must be found out through the given product id.
|
|
if not line_id:
|
|
line_id = order_sudo.order_line.filtered(
|
|
lambda sol: sol.product_id.id == product_id
|
|
)[:1].id
|
|
|
|
values = order_sudo._cart_update_line_quantity(line_id, quantity, **kwargs)
|
|
|
|
values['cart_quantity'] = order_sudo.cart_quantity
|
|
values['cart_ready'] = order_sudo._is_cart_ready()
|
|
values['amount'] = order_sudo.amount_total
|
|
values['minor_amount'] = (
|
|
order_sudo and payment_utils.to_minor_currency_units(
|
|
order_sudo.amount_total, order_sudo.currency_id
|
|
)
|
|
) or 0.0
|
|
values['website_sale.cart_lines'] = IrUiView._render_template(
|
|
'website_sale.cart_lines', {
|
|
'website_sale_order': order_sudo,
|
|
'date': fields.Date.today(),
|
|
'suggested_products': order_sudo._cart_accessories()
|
|
}
|
|
)
|
|
values['website_sale.total'] = IrUiView._render_template(
|
|
'website_sale.total', {
|
|
'website_sale_order': order_sudo,
|
|
}
|
|
)
|
|
values['website_sale.quick_reorder_history'] = IrUiView._render_template(
|
|
'website_sale.quick_reorder_history', {
|
|
'website_sale_order': order_sudo,
|
|
**self._prepare_order_history(),
|
|
}
|
|
)
|
|
return values
|
|
|
|
def _prepare_order_history(self):
|
|
"""Prepare the order history of the current user.
|
|
|
|
The valid order lines of the last 10 confirmed orders are considered and grouped by date. An
|
|
order line is not valid if:
|
|
|
|
- Its product is already in the cart.
|
|
- It's a combo parent line.
|
|
- It has an unsellable product.
|
|
- It has a zero-priced product (if the website blocks them).
|
|
- It has an already seen product (duplicate or identical combo).
|
|
|
|
The dates are represented by labels like "Today", "Yesterday", or "X days ago".
|
|
|
|
:return: The order history, in the format
|
|
{'order_history': [{'label': str, 'lines': SaleOrderLine}, ...]}.
|
|
:rtype: dict
|
|
"""
|
|
def is_same_combo(line1_, line2_):
|
|
"""Check if two combo lines have the same linked product combination."""
|
|
return line1_.linked_line_ids.product_id.ids == line2_.linked_line_ids.product_id.ids
|
|
|
|
# Get the last 10 confirmed orders from the current website user.
|
|
previous_orders_lines_sudo = request.env['sale.order'].sudo().search(
|
|
[
|
|
('partner_id', '=', request.env.user.partner_id.id),
|
|
('state', '=', 'sale'),
|
|
('website_id', '=', request.website.id),
|
|
],
|
|
order='date_order desc',
|
|
limit=10,
|
|
).order_line
|
|
|
|
# Prepare the order history.
|
|
SaleOrderLineSudo = request.env['sale.order.line'].sudo()
|
|
cart_lines_sudo = request.cart.order_line if request.cart else SaleOrderLineSudo
|
|
seen_lines_sudo = SaleOrderLineSudo
|
|
lines_per_order_date = {}
|
|
for line_sudo in previous_orders_lines_sudo:
|
|
# Ignore lines that are combo parents, unsellable, or zero-priced.
|
|
product_id = line_sudo.product_id.id
|
|
if (
|
|
line_sudo.linked_line_id.product_type == 'combo'
|
|
or not line_sudo._is_sellable()
|
|
or (
|
|
request.website.prevent_zero_price_sale
|
|
and line_sudo.product_id._get_combination_info_variant()['price'] == 0
|
|
)
|
|
):
|
|
continue
|
|
|
|
# Ignore lines that are already in the cart or have already been seen.
|
|
is_combo = line_sudo.product_type == 'combo'
|
|
if any(
|
|
l.product_id.id == product_id and (not is_combo or is_same_combo(line_sudo, l))
|
|
for l in cart_lines_sudo + seen_lines_sudo
|
|
):
|
|
continue
|
|
seen_lines_sudo |= line_sudo
|
|
|
|
# Group lines by date.
|
|
days_ago = (fields.Date.today() - line_sudo.order_id.date_order.date()).days
|
|
if days_ago == 0:
|
|
line_group_label = self.env._("Today")
|
|
elif days_ago == 1:
|
|
line_group_label = self.env._("Yesterday")
|
|
else:
|
|
line_group_label = self.env._("%s days ago", days_ago)
|
|
lines_per_order_date.setdefault(line_group_label, SaleOrderLineSudo)
|
|
lines_per_order_date[line_group_label] |= line_sudo
|
|
|
|
# Flatten the line groups to get the final order history.
|
|
return {
|
|
'order_history': [
|
|
{'label': label, 'lines': lines} for label, lines in lines_per_order_date.items()
|
|
]
|
|
}
|
|
|
|
@route(
|
|
route='/shop/cart/quantity',
|
|
type='jsonrpc',
|
|
auth='public',
|
|
methods=['POST'],
|
|
website=True
|
|
)
|
|
def cart_quantity(self):
|
|
if 'website_sale_cart_quantity' not in request.session:
|
|
return request.cart.cart_quantity
|
|
return request.session['website_sale_cart_quantity']
|
|
|
|
@route(
|
|
route='/shop/cart/clear',
|
|
type='jsonrpc',
|
|
auth='public',
|
|
website=True
|
|
)
|
|
def clear_cart(self):
|
|
request.cart.order_line.unlink()
|
|
|
|
def _get_cart_notification_information(self, order, added_qty_per_line):
|
|
""" Get the information about the sales order lines to show in the notification.
|
|
|
|
:param sale.order order: The sales order.
|
|
:param dict added_qty_per_line: The added qty per order line.
|
|
:rtype: dict
|
|
:return: A dict with the following structure:
|
|
{
|
|
'currency_id': int
|
|
'lines': [{
|
|
'id': int
|
|
'image_url': int
|
|
'quantity': float
|
|
'name': str
|
|
'description': str
|
|
'added_qty_price_total': float
|
|
}],
|
|
}
|
|
"""
|
|
lines = order.order_line.filtered(lambda line: line.id in set(added_qty_per_line))
|
|
if not lines:
|
|
return {}
|
|
|
|
show_tax = order.website_id.show_line_subtotals_tax_selection == 'tax_included'
|
|
return {
|
|
'currency_id': order.currency_id.id,
|
|
'lines': [
|
|
{ # For the cart_notification
|
|
'id': line.id,
|
|
'image_url': order.website_id.image_url(line.product_id, 'image_128'),
|
|
'quantity': added_qty_per_line[line.id],
|
|
'name': line._get_line_header(),
|
|
'combination_name': line._get_combination_name(),
|
|
'description': line._get_sale_order_line_multiline_description_variants(),
|
|
'price_total': (
|
|
line.price_reduce_taxinc
|
|
if show_tax else line.price_reduce_taxexcl
|
|
) * added_qty_per_line[line.id],
|
|
**self._get_additional_cart_notification_information(line),
|
|
} for line in lines
|
|
],
|
|
}
|
|
|
|
def _get_tracking_information(self, order_sudo, line_ids):
|
|
""" Get the tracking information about the sales order lines.
|
|
|
|
:param sale.order order: The sales order.
|
|
:param list[int] line_ids: The ids of the lines to track.
|
|
:rtype: dict
|
|
:return: The tracking information.
|
|
"""
|
|
lines = order_sudo.order_line.filtered(
|
|
lambda line: line.id in line_ids
|
|
).with_context(display_default_code=False)
|
|
return [
|
|
{
|
|
'item_id': line.product_id.barcode or line.product_id.id,
|
|
'item_name': line.product_id.display_name,
|
|
'item_category': line.product_id.categ_id.name,
|
|
'currency': line.currency_id.name,
|
|
'price': line.price_reduce_taxexcl,
|
|
'discount': line.price_unit - line.price_reduce_taxexcl,
|
|
'quantity': line.product_uom_qty,
|
|
} for line in lines
|
|
]
|
|
|
|
def _get_additional_cart_update_values(self, data):
|
|
""" Look for extra information in a given dictionary to be included in a `_cart_add` call.
|
|
|
|
:param dict data: A dictionary in which to look up for extra information.
|
|
:return: addition values to be passed to `_cart_add`.
|
|
:rtype: dict
|
|
"""
|
|
if data.get('combo_item_id'):
|
|
return {'combo_item_id': data['combo_item_id']}
|
|
return {}
|
|
|
|
def _get_additional_cart_notification_information(self, line):
|
|
infos = {}
|
|
# Only set the linked line id for combo items, not for optional products.
|
|
if combo_item := line.combo_item_id:
|
|
infos['linked_line_id'] = line.linked_line_id.id
|
|
# To sell a product type 'combo', one doesn't need to publish all combo choices. This
|
|
# causes an issue when public users access the image of each choice via the /web/image
|
|
# route. To bypass this access check, we send the raw image URL if the product is
|
|
# inaccessible to the current user.
|
|
if (
|
|
not combo_item.product_id.sudo(False).has_access('read')
|
|
and combo_item.product_id.image_128
|
|
):
|
|
infos['image_url'] = image_data_uri(combo_item.product_id.image_128)
|
|
|
|
if line.product_template_id._has_multiple_uoms():
|
|
infos['uom_name'] = line.product_uom_id.name
|
|
|
|
return infos
|