mirror of
https://github.com/bringout/oca-ocb-sale.git
synced 2026-04-26 21:32:04 +02:00
19.0 vanilla
This commit is contained in:
parent
79f83631d5
commit
73afc09215
6267 changed files with 1534193 additions and 1130106 deletions
|
|
@ -1,6 +1,13 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import backend
|
||||
from . import cart
|
||||
from . import combo_configurator
|
||||
from . import delivery
|
||||
from . import main
|
||||
from . import payment
|
||||
from . import product_configurator
|
||||
from . import product_feed
|
||||
from . import reorder
|
||||
from . import sale
|
||||
from . import variant
|
||||
from . import website
|
||||
|
|
|
|||
|
|
@ -1,183 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import babel.dates
|
||||
|
||||
from datetime import datetime, timedelta, time
|
||||
|
||||
from odoo import fields, http, _
|
||||
from odoo.addons.website.controllers.backend import WebsiteBackend
|
||||
from odoo.http import request
|
||||
from odoo.tools.misc import get_lang
|
||||
|
||||
|
||||
class WebsiteSaleBackend(WebsiteBackend):
|
||||
|
||||
@http.route()
|
||||
def fetch_dashboard_data(self, website_id, date_from, date_to):
|
||||
Website = request.env['website']
|
||||
current_website = website_id and Website.browse(website_id) or Website.get_current_website()
|
||||
|
||||
results = super(WebsiteSaleBackend, self).fetch_dashboard_data(website_id, date_from, date_to)
|
||||
|
||||
date_date_from = fields.Date.from_string(date_from)
|
||||
date_date_to = fields.Date.from_string(date_to)
|
||||
date_diff_days = (date_date_to - date_date_from).days
|
||||
datetime_from = datetime.combine(date_date_from, time.min)
|
||||
datetime_to = datetime.combine(date_date_to, time.max)
|
||||
|
||||
sales_values = dict(
|
||||
graph=[],
|
||||
best_sellers=[],
|
||||
summary=dict(
|
||||
order_count=0, order_carts_count=0, order_unpaid_count=0,
|
||||
order_to_invoice_count=0, order_carts_abandoned_count=0,
|
||||
payment_to_capture_count=0, total_sold=0,
|
||||
order_per_day_ratio=0, order_sold_ratio=0, order_convertion_pctg=0,
|
||||
)
|
||||
)
|
||||
|
||||
results['dashboards']['sales'] = sales_values
|
||||
|
||||
results['groups']['sale_salesman'] = request.env['res.users'].has_group('sales_team.group_sale_salesman')
|
||||
|
||||
if not results['groups']['sale_salesman']:
|
||||
return results
|
||||
|
||||
results['dashboards']['sales']['utm_graph'] = self.fetch_utm_data(datetime_from, datetime_to)
|
||||
# Product-based computation
|
||||
sale_report_domain = [
|
||||
('website_id', '=', current_website.id),
|
||||
('state', 'in', ['sale', 'done']),
|
||||
('date', '>=', datetime_from),
|
||||
('date', '<=', fields.Datetime.now())
|
||||
]
|
||||
report_product_lines = request.env['sale.report'].read_group(
|
||||
domain=sale_report_domain,
|
||||
fields=['product_tmpl_id', 'product_uom_qty', 'price_subtotal'],
|
||||
groupby='product_tmpl_id', orderby='product_uom_qty desc', limit=5)
|
||||
for product_line in report_product_lines:
|
||||
product_tmpl_id = request.env['product.template'].browse(product_line['product_tmpl_id'][0])
|
||||
sales_values['best_sellers'].append({
|
||||
'id': product_tmpl_id.id,
|
||||
'name': product_tmpl_id.name,
|
||||
'qty': product_line['product_uom_qty'],
|
||||
'sales': product_line['price_subtotal'],
|
||||
})
|
||||
|
||||
# Sale-based results computation
|
||||
sale_order_domain = [
|
||||
('website_id', '=', current_website.id),
|
||||
('date_order', '>=', fields.Datetime.to_string(datetime_from)),
|
||||
('date_order', '<=', fields.Datetime.to_string(datetime_to))]
|
||||
so_group_data = request.env['sale.order'].read_group(sale_order_domain, fields=['state'], groupby='state')
|
||||
for res in so_group_data:
|
||||
if res.get('state') == 'sent':
|
||||
sales_values['summary']['order_unpaid_count'] += res['state_count']
|
||||
elif res.get('state') in ['sale', 'done']:
|
||||
sales_values['summary']['order_count'] += res['state_count']
|
||||
sales_values['summary']['order_carts_count'] += res['state_count']
|
||||
|
||||
report_price_lines = request.env['sale.report'].read_group(
|
||||
domain=[
|
||||
('website_id', '=', current_website.id),
|
||||
('state', 'in', ['sale', 'done']),
|
||||
('date', '>=', datetime_from),
|
||||
('date', '<=', datetime_to)],
|
||||
fields=['team_id', 'price_subtotal'],
|
||||
groupby=['team_id'],
|
||||
)
|
||||
sales_values['summary'].update(
|
||||
order_to_invoice_count=request.env['sale.order'].search_count(sale_order_domain + [
|
||||
('state', 'in', ['sale', 'done']),
|
||||
('order_line', '!=', False),
|
||||
('partner_id', '!=', request.env.ref('base.public_partner').id),
|
||||
('invoice_status', '=', 'to invoice'),
|
||||
]),
|
||||
order_carts_abandoned_count=request.env['sale.order'].search_count(sale_order_domain + [
|
||||
('is_abandoned_cart', '=', True),
|
||||
('cart_recovery_email_sent', '=', False)
|
||||
]),
|
||||
payment_to_capture_count=request.env['payment.transaction'].search_count([
|
||||
('state', '=', 'authorized'),
|
||||
# that part perform a search on sale.order in order to comply with access rights as tx do not have any
|
||||
('sale_order_ids', 'in', request.env['sale.order'].search(sale_order_domain + [('state', '!=', 'cancel')]).ids),
|
||||
]),
|
||||
total_sold=sum(price_line['price_subtotal'] for price_line in report_price_lines)
|
||||
)
|
||||
|
||||
# Ratio computation
|
||||
sales_values['summary']['order_per_day_ratio'] = round(float(sales_values['summary']['order_count']) / date_diff_days, 2)
|
||||
sales_values['summary']['order_sold_ratio'] = round(float(sales_values['summary']['total_sold']) / sales_values['summary']['order_count'], 2) if sales_values['summary']['order_count'] else 0
|
||||
sales_values['summary']['order_convertion_pctg'] = 100.0 * sales_values['summary']['order_count'] / sales_values['summary']['order_carts_count'] if sales_values['summary']['order_carts_count'] else 0
|
||||
|
||||
# Graphes computation
|
||||
if date_diff_days == 7:
|
||||
previous_sale_label = _('Previous Week')
|
||||
elif date_diff_days > 7 and date_diff_days <= 31:
|
||||
previous_sale_label = _('Previous Month')
|
||||
else:
|
||||
previous_sale_label = _('Previous Year')
|
||||
|
||||
sales_values['graph'] += [{
|
||||
'values': self._compute_sale_graph(date_date_from, date_date_to, sale_report_domain),
|
||||
'key': 'Untaxed Total',
|
||||
}, {
|
||||
'values': self._compute_sale_graph(date_date_from - timedelta(days=date_diff_days), date_date_from, sale_report_domain, previous=True),
|
||||
'key': previous_sale_label,
|
||||
}]
|
||||
|
||||
return results
|
||||
|
||||
def fetch_utm_data(self, date_from, date_to):
|
||||
sale_utm_domain = [
|
||||
('website_id', '!=', False),
|
||||
('state', 'in', ['sale', 'done']),
|
||||
('date_order', '>=', date_from),
|
||||
('date_order', '<=', date_to)
|
||||
]
|
||||
|
||||
orders_data_groupby_campaign_id = request.env['sale.order']._read_group(
|
||||
domain=sale_utm_domain + [('campaign_id', '!=', False)],
|
||||
fields=['amount_total', 'id', 'campaign_id'],
|
||||
groupby='campaign_id')
|
||||
|
||||
orders_data_groupby_medium_id = request.env['sale.order']._read_group(
|
||||
domain=sale_utm_domain + [('medium_id', '!=', False)],
|
||||
fields=['amount_total', 'id', 'medium_id'],
|
||||
groupby='medium_id')
|
||||
|
||||
orders_data_groupby_source_id = request.env['sale.order']._read_group(
|
||||
domain=sale_utm_domain + [('source_id', '!=', False)],
|
||||
fields=['amount_total', 'id', 'source_id'],
|
||||
groupby='source_id')
|
||||
|
||||
return {
|
||||
'campaign_id': self.compute_utm_graph_data('campaign_id', orders_data_groupby_campaign_id),
|
||||
'medium_id': self.compute_utm_graph_data('medium_id', orders_data_groupby_medium_id),
|
||||
'source_id': self.compute_utm_graph_data('source_id', orders_data_groupby_source_id),
|
||||
}
|
||||
|
||||
def compute_utm_graph_data(self, utm_type, utm_graph_data):
|
||||
return [{
|
||||
'utm_type': data[utm_type][1],
|
||||
'amount_total': data['amount_total']
|
||||
} for data in utm_graph_data]
|
||||
|
||||
def _compute_sale_graph(self, date_from, date_to, sales_domain, previous=False):
|
||||
days_between = (date_to - date_from).days
|
||||
date_list = [(date_from + timedelta(days=x)) for x in range(0, days_between + 1)]
|
||||
|
||||
daily_sales = request.env['sale.report'].read_group(
|
||||
domain=sales_domain,
|
||||
fields=['date', 'price_subtotal'],
|
||||
groupby='date:day')
|
||||
|
||||
daily_sales_dict = {p['date:day']: p['price_subtotal'] for p in daily_sales}
|
||||
|
||||
sales_graph = [{
|
||||
'0': fields.Date.to_string(d) if not previous else fields.Date.to_string(d + timedelta(days=days_between)),
|
||||
# Respect read_group format in models.py
|
||||
'1': daily_sales_dict.get(babel.dates.format_date(d, format='dd MMM yyyy', locale=get_lang(request.env).code), 0)
|
||||
} for d in date_list]
|
||||
|
||||
return sales_graph
|
||||
|
|
@ -0,0 +1,539 @@
|
|||
# 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
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.http import request, route
|
||||
from odoo.tools.image import image_data_uri
|
||||
|
||||
from odoo.addons.sale.controllers.combo_configurator import SaleComboConfiguratorController
|
||||
from odoo.addons.website_sale.controllers.main import WebsiteSale
|
||||
|
||||
|
||||
class WebsiteSaleComboConfiguratorController(SaleComboConfiguratorController, WebsiteSale):
|
||||
|
||||
@route(
|
||||
route='/website_sale/combo_configurator/get_data',
|
||||
type='jsonrpc',
|
||||
auth='public',
|
||||
website=True,
|
||||
readonly=True,
|
||||
)
|
||||
def website_sale_combo_configurator_get_data(self, *args, **kwargs):
|
||||
self._populate_currency_and_pricelist(kwargs)
|
||||
request.update_context(display_default_code=False) # Hide internal product reference
|
||||
return super().sale_combo_configurator_get_data(*args, **kwargs)
|
||||
|
||||
@route(
|
||||
route='/website_sale/combo_configurator/get_price',
|
||||
type='jsonrpc',
|
||||
auth='public',
|
||||
website=True,
|
||||
readonly=True,
|
||||
)
|
||||
def website_sale_combo_configurator_get_price(self, *args, **kwargs):
|
||||
self._populate_currency_and_pricelist(kwargs)
|
||||
return super().sale_combo_configurator_get_price(*args, **kwargs)
|
||||
|
||||
def _get_combo_item_data(
|
||||
self, combo, combo_item, selected_combo_item, date, currency, pricelist, **kwargs
|
||||
):
|
||||
data = super()._get_combo_item_data(
|
||||
combo, combo_item, selected_combo_item, date, currency, pricelist, **kwargs
|
||||
)
|
||||
# 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_image := combo_item.product_id.image_256)
|
||||
):
|
||||
data['product']['image_src'] = image_data_uri(combo_item_image)
|
||||
return data
|
||||
|
|
@ -0,0 +1,288 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.http import request, route
|
||||
|
||||
from odoo.addons.payment import utils as payment_utils
|
||||
from odoo.addons.website_sale.controllers.main import WebsiteSale
|
||||
|
||||
|
||||
class Delivery(WebsiteSale):
|
||||
_express_checkout_delivery_route = '/shop/express/shipping_address_change'
|
||||
|
||||
@route('/shop/delivery_methods', type='jsonrpc', auth='public', website=True)
|
||||
def shop_delivery_methods(self):
|
||||
""" Fetch available delivery methods and render them in the delivery form.
|
||||
|
||||
:return: The rendered delivery form.
|
||||
:rtype: str
|
||||
"""
|
||||
order_sudo = request.cart
|
||||
values = {
|
||||
'delivery_methods': order_sudo._get_delivery_methods(),
|
||||
'selected_dm_id': order_sudo.carrier_id.id,
|
||||
'order': order_sudo, # Needed for accessing default values for pickup points.
|
||||
}
|
||||
values |= self._get_additional_delivery_context()
|
||||
return request.env['ir.ui.view']._render_template('website_sale.delivery_form', values)
|
||||
|
||||
def _get_additional_delivery_context(self):
|
||||
""" Hook to update values used for rendering the website_sale.delivery_form template. """
|
||||
return {}
|
||||
|
||||
@route('/shop/set_delivery_method', type='jsonrpc', auth='public', website=True)
|
||||
def shop_set_delivery_method(self, dm_id=None, **kwargs):
|
||||
""" Set the delivery method on the current order and return the order summary values.
|
||||
|
||||
If the delivery method is already set, the order summary values are returned immediately.
|
||||
|
||||
:param str dm_id: The delivery method to set, as a `delivery.carrier` id.
|
||||
:param dict kwargs: The keyword arguments forwarded to `_order_summary_values`.
|
||||
:return: The order summary values, if any.
|
||||
:rtype: dict
|
||||
"""
|
||||
if not (order_sudo := request.cart):
|
||||
return {}
|
||||
|
||||
dm_id = int(dm_id)
|
||||
if dm_id in order_sudo._get_delivery_methods().ids and dm_id != order_sudo.carrier_id.id:
|
||||
for tx_sudo in order_sudo.transaction_ids:
|
||||
if tx_sudo.state not in ('draft', 'cancel', 'error'):
|
||||
raise UserError(_(
|
||||
"It seems that there is already a transaction for your order; you can't"
|
||||
" change the delivery method anymore."
|
||||
))
|
||||
|
||||
delivery_method_sudo = request.env['delivery.carrier'].sudo().browse(dm_id).exists()
|
||||
order_sudo._set_delivery_method(delivery_method_sudo)
|
||||
return self._order_summary_values(order_sudo, **kwargs)
|
||||
|
||||
def _order_summary_values(self, order, **kwargs):
|
||||
""" Return the summary values of the order.
|
||||
|
||||
:param sale.order order: The sales order whose summary values to return.
|
||||
:param dict kwargs: The keyword arguments. This parameter is not used here.
|
||||
:return: The order summary values.
|
||||
:rtype: dict
|
||||
"""
|
||||
Monetary = request.env['ir.qweb.field.monetary']
|
||||
currency = order.currency_id
|
||||
return {
|
||||
'success': True,
|
||||
'is_free_delivery': not bool(order.amount_delivery),
|
||||
'compute_price_after_delivery': order.carrier_id.invoice_policy == 'real',
|
||||
'amount_delivery': Monetary.value_to_html(
|
||||
order.amount_delivery, {'display_currency': currency}
|
||||
),
|
||||
'amount_untaxed': Monetary.value_to_html(
|
||||
order.amount_untaxed, {'display_currency': currency}
|
||||
),
|
||||
'amount_tax': Monetary.value_to_html(
|
||||
order.amount_tax, {'display_currency': currency}
|
||||
),
|
||||
'amount_total': Monetary.value_to_html(
|
||||
order.amount_total, {'display_currency': currency}
|
||||
),
|
||||
}
|
||||
|
||||
@route('/shop/get_delivery_rate', type='jsonrpc', auth='public', methods=['POST'], website=True)
|
||||
def shop_get_delivery_rate(self, dm_id):
|
||||
""" Return the delivery rate data for the given delivery method.
|
||||
|
||||
:param str dm_id: The delivery method whose rate to get, as a `delivery.carrier` id.
|
||||
:return: The delivery rate data.
|
||||
:rtype: dict
|
||||
"""
|
||||
if not (order_sudo := request.cart):
|
||||
raise ValidationError(_("Your cart is empty."))
|
||||
|
||||
if int(dm_id) not in order_sudo._get_delivery_methods().ids:
|
||||
raise UserError(_(
|
||||
"It seems that a delivery method is not compatible with your address. Please"
|
||||
" refresh the page and try again."
|
||||
))
|
||||
|
||||
Monetary = request.env['ir.qweb.field.monetary']
|
||||
delivery_method = request.env['delivery.carrier'].sudo().browse(int(dm_id)).exists()
|
||||
rate = Delivery._get_rate(delivery_method, order_sudo)
|
||||
if rate['success']:
|
||||
rate['amount_delivery'] = Monetary.value_to_html(
|
||||
rate['price'], {'display_currency': order_sudo.currency_id}
|
||||
)
|
||||
rate['is_free_delivery'] = not bool(rate['price'])
|
||||
rate['compute_price_after_delivery'] = delivery_method.invoice_policy == 'real'
|
||||
else:
|
||||
rate['amount_delivery'] = Monetary.value_to_html(
|
||||
0.0, {'display_currency': order_sudo.currency_id}
|
||||
)
|
||||
return rate
|
||||
|
||||
@route('/website_sale/set_pickup_location', type='jsonrpc', auth='public', website=True)
|
||||
def website_sale_set_pickup_location(self, pickup_location_data):
|
||||
""" Fetch the order from the request and set the pickup location on the current order.
|
||||
|
||||
:param str pickup_location_data: The JSON-formatted pickup location address.
|
||||
:return: None
|
||||
"""
|
||||
order_sudo = request.cart
|
||||
order_sudo._set_pickup_location(pickup_location_data)
|
||||
|
||||
@route('/website_sale/get_pickup_locations', type='jsonrpc', auth='public', website=True)
|
||||
def website_sale_get_pickup_locations(self, zip_code=None, **kwargs):
|
||||
""" Fetch the order from the request and return the pickup locations close to the zip code.
|
||||
|
||||
Determine the country based on GeoIP or fallback on the order's delivery address' country.
|
||||
|
||||
:param int zip_code: The zip code to look up to.
|
||||
:return: The close pickup locations data.
|
||||
:rtype: dict
|
||||
"""
|
||||
order_sudo = request.cart
|
||||
country = order_sudo.partner_shipping_id.country_id
|
||||
return order_sudo._get_pickup_locations(zip_code, country, **kwargs)
|
||||
|
||||
@route(_express_checkout_delivery_route, type='jsonrpc', auth='public', website=True)
|
||||
def express_checkout_process_delivery_address(self, partial_delivery_address):
|
||||
""" Process the shipping address and return the available delivery methods.
|
||||
|
||||
Depending on whether the partner is registered and logged in, a new partner is created or we
|
||||
use an existing partner that matches the partial delivery address received.
|
||||
|
||||
:param dict partial_delivery_address: The delivery information sent by the express payment
|
||||
provider.
|
||||
:return: The available delivery methods, sorted by lowest price.
|
||||
:rtype: dict
|
||||
"""
|
||||
if not (order_sudo := request.cart):
|
||||
return []
|
||||
|
||||
self._include_country_and_state_in_address(partial_delivery_address)
|
||||
partial_delivery_address, _side_values = self._parse_form_data(partial_delivery_address)
|
||||
if order_sudo._is_anonymous_cart():
|
||||
# The partner_shipping_id and partner_invoice_id will be automatically computed when
|
||||
# changing the partner_id of the SO. This allows website_sale to avoid creating
|
||||
# duplicates.
|
||||
partial_delivery_address['name'] = _(
|
||||
'Anonymous express checkout partner for order %s',
|
||||
order_sudo.name,
|
||||
)
|
||||
new_partner_sudo = self._create_new_address(
|
||||
address_values=partial_delivery_address,
|
||||
address_type='delivery',
|
||||
use_delivery_as_billing=False,
|
||||
order_sudo=order_sudo,
|
||||
)
|
||||
# Pricelists are recomputed every time the partner is changed. We don't want to
|
||||
# recompute the price with another pricelist at this state since the customer has
|
||||
# already accepted the amount and validated the payment.
|
||||
with request.env.protecting([order_sudo._fields['pricelist_id']], order_sudo):
|
||||
order_sudo.partner_id = new_partner_sudo
|
||||
elif order_sudo.name in order_sudo.partner_shipping_id.name:
|
||||
order_sudo.partner_shipping_id.write(partial_delivery_address)
|
||||
# TODO VFE TODO VCR do we want to trigger cart recomputation here ?
|
||||
# order_sudo._update_address(
|
||||
# order_sudo.partner_shipping_id.id, ['partner_shipping_id']
|
||||
# )
|
||||
elif not self._are_same_addresses(
|
||||
partial_delivery_address,
|
||||
order_sudo.partner_shipping_id,
|
||||
):
|
||||
# Check if a child partner doesn't already exist with the same information. The phone
|
||||
# isn't always checked because it isn't sent in delivery information with Google Pay.
|
||||
child_partner_id = self._find_child_partner(
|
||||
order_sudo.partner_id.commercial_partner_id.id, partial_delivery_address
|
||||
)
|
||||
partial_delivery_address['name'] = _(
|
||||
'Anonymous express checkout partner for order %s',
|
||||
order_sudo.name,
|
||||
)
|
||||
order_sudo.partner_shipping_id = child_partner_id or self._create_new_address(
|
||||
address_values=partial_delivery_address,
|
||||
address_type='delivery',
|
||||
use_delivery_as_billing=False,
|
||||
order_sudo=order_sudo,
|
||||
)
|
||||
|
||||
sorted_delivery_methods = sorted([{
|
||||
'id': dm.id,
|
||||
'name': dm.name,
|
||||
'description': dm.website_description,
|
||||
'minorAmount': payment_utils.to_minor_currency_units(price, order_sudo.currency_id),
|
||||
} for dm, price in self._get_delivery_methods_express_checkout(order_sudo).items()
|
||||
], key=lambda dm: dm['minorAmount'])
|
||||
|
||||
# Preselect the cheapest method imitating the behavior of the express checkout form.
|
||||
if (
|
||||
sorted_delivery_methods
|
||||
and order_sudo.carrier_id.id != sorted_delivery_methods[0]['id']
|
||||
and (cheapest_dm := next((
|
||||
dm for dm in order_sudo._get_delivery_methods()
|
||||
if dm.id == sorted_delivery_methods[0]['id']), None
|
||||
))
|
||||
):
|
||||
order_sudo._set_delivery_method(cheapest_dm)
|
||||
|
||||
# Return the list of delivery methods available for the sales order.
|
||||
return {'delivery_methods': sorted_delivery_methods}
|
||||
|
||||
@classmethod
|
||||
def _get_delivery_methods_express_checkout(cls, order_sudo):
|
||||
""" Return available delivery methods and their prices for the given order.
|
||||
|
||||
:param sale.order order_sudo: The sudoed sales order.
|
||||
:rtype: dict
|
||||
:return: A dict with a `delivery.carrier` recordset as key, and a rate shipment price as
|
||||
value.
|
||||
"""
|
||||
res = {}
|
||||
for dm in order_sudo._get_delivery_methods():
|
||||
rate = Delivery._get_rate(dm, order_sudo, is_express_checkout_flow=True)
|
||||
if rate['success']:
|
||||
fname = f'{dm.delivery_type}_use_locations'
|
||||
if hasattr(dm, fname) and getattr(dm, fname):
|
||||
continue # Express checkout doesn't allow selecting locations.
|
||||
res[dm] = rate['price']
|
||||
return res
|
||||
|
||||
@staticmethod
|
||||
def _get_rate(delivery_method, order, is_express_checkout_flow=False):
|
||||
""" Compute the delivery rate and apply the taxes if relevant.
|
||||
|
||||
:param delivery.carrier delivery_method: The delivery method for which the rate must be
|
||||
computed.
|
||||
:param sale.order order: The current sales order.
|
||||
:param boolean is_express_checkout_flow: Whether the flow is express checkout.
|
||||
:return: The delivery rate data.
|
||||
:rtype: dict
|
||||
"""
|
||||
# Some delivery methods check if all the required fields are available before computing the
|
||||
# rate, even if those fields aren't required for the computation (although they are for
|
||||
# delivering the goods). If we only have partial information about the delivery address, but
|
||||
# still want to compute the rate, this context key will ensure that we only check the
|
||||
# required fields for a partial delivery address (city, zip, country_code, state_code).
|
||||
rate = delivery_method.rate_shipment(order.with_context(
|
||||
express_checkout_partial_delivery_address=is_express_checkout_flow
|
||||
))
|
||||
if rate.get('success'):
|
||||
tax_ids = delivery_method.product_id.taxes_id.filtered(
|
||||
lambda t: t.company_id == order.company_id
|
||||
)
|
||||
if tax_ids:
|
||||
fpos = order.fiscal_position_id
|
||||
tax_ids = fpos.map_tax(tax_ids)
|
||||
taxes = tax_ids.compute_all(
|
||||
rate['price'],
|
||||
currency=order.currency_id,
|
||||
quantity=1.0,
|
||||
product=delivery_method.product_id,
|
||||
partner=order.partner_shipping_id,
|
||||
)
|
||||
if (
|
||||
not is_express_checkout_flow
|
||||
and request.website.show_line_subtotals_tax_selection == 'tax_excluded'
|
||||
):
|
||||
rate['price'] = taxes['total_excluded']
|
||||
else:
|
||||
rate['price'] = taxes['total_included']
|
||||
return rate
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,86 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from psycopg2.errors import LockNotAvailable
|
||||
|
||||
from odoo import _
|
||||
from odoo.exceptions import AccessError, MissingError, UserError, ValidationError
|
||||
from odoo.fields import Command
|
||||
from odoo.http import request, route
|
||||
from odoo.tools import SQL
|
||||
|
||||
from odoo.addons.payment.controllers import portal as payment_portal
|
||||
|
||||
|
||||
# TODO ANVFE part of payment routes ? /shop/payment ? express_checkout ?
|
||||
|
||||
class PaymentPortal(payment_portal.PaymentPortal):
|
||||
|
||||
def _validate_transaction_for_order(self, transaction, sale_order):
|
||||
"""
|
||||
Perform final checks against the transaction & sale_order.
|
||||
Override me to apply payment unrelated checks & processing
|
||||
"""
|
||||
return
|
||||
|
||||
@route('/shop/payment/transaction/<int:order_id>', type='jsonrpc', auth='public', website=True)
|
||||
def shop_payment_transaction(self, order_id, access_token, **kwargs):
|
||||
""" Create a draft transaction and return its processing values.
|
||||
|
||||
:param int order_id: The sales order to pay, as a `sale.order` id
|
||||
:param str access_token: The access token used to authenticate the request
|
||||
:param dict kwargs: Locally unused data passed to `_create_transaction`
|
||||
:return: The mandatory values for the processing of the transaction
|
||||
:rtype: dict
|
||||
:raise: UserError if the order has already been paid or has an ongoing transaction
|
||||
:raise: ValidationError if the access token is invalid or the order is not in the expected
|
||||
state/configuration.
|
||||
"""
|
||||
# Check the order id and the access token
|
||||
# Then lock it during the transaction to prevent concurrent payments
|
||||
try:
|
||||
order_sudo = self._document_check_access('sale.order', order_id, access_token)
|
||||
request.env.cr.execute(
|
||||
SQL('SELECT 1 FROM sale_order WHERE id = %s FOR NO KEY UPDATE NOWAIT', order_id)
|
||||
)
|
||||
except MissingError:
|
||||
raise
|
||||
except AccessError as e:
|
||||
raise ValidationError(_("The access token is invalid.")) from e
|
||||
except LockNotAvailable:
|
||||
raise UserError(_("Payment is already being processed."))
|
||||
|
||||
if order_sudo.state == "cancel":
|
||||
raise ValidationError(_("The order has been cancelled."))
|
||||
|
||||
order_sudo._check_cart_is_ready_to_be_paid()
|
||||
|
||||
self._validate_transaction_kwargs(kwargs)
|
||||
kwargs.update({
|
||||
'partner_id': order_sudo.partner_invoice_id.id,
|
||||
'currency_id': order_sudo.currency_id.id,
|
||||
'sale_order_id': order_id, # Include the SO to allow Subscriptions to tokenize the tx
|
||||
})
|
||||
if not kwargs.get('amount'):
|
||||
kwargs['amount'] = order_sudo.amount_total
|
||||
|
||||
compare_amounts = order_sudo.currency_id.compare_amounts
|
||||
if compare_amounts(kwargs['amount'], order_sudo.amount_total):
|
||||
raise ValidationError(_("The cart has been updated. Please refresh the page."))
|
||||
if compare_amounts(order_sudo.amount_paid, order_sudo.amount_total) == 0:
|
||||
raise UserError(_("The cart has already been paid. Please refresh the page."))
|
||||
|
||||
if delay_token_charge := kwargs.get('flow') == 'token':
|
||||
request.update_context(delay_token_charge=True) # wait until after tx validation
|
||||
tx_sudo = self._create_transaction(
|
||||
custom_create_values={'sale_order_ids': [Command.set([order_id])]}, **kwargs,
|
||||
)
|
||||
|
||||
# Store the new transaction into the transaction list and if there's an old one, we remove
|
||||
# it until the day the ecommerce supports multiple orders at the same time.
|
||||
request.session['__website_sale_last_tx_id'] = tx_sudo.id
|
||||
|
||||
self._validate_transaction_for_order(tx_sudo, order_sudo)
|
||||
if delay_token_charge:
|
||||
tx_sudo._charge_with_token()
|
||||
|
||||
return tx_sudo._get_processing_values()
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.http import request, route
|
||||
from odoo.tools import float_is_zero
|
||||
|
||||
from odoo.addons.sale.controllers.product_configurator import SaleProductConfiguratorController
|
||||
from odoo.addons.website_sale.controllers.main import WebsiteSale
|
||||
|
||||
|
||||
class WebsiteSaleProductConfiguratorController(SaleProductConfiguratorController, WebsiteSale):
|
||||
|
||||
@route(
|
||||
route='/website_sale/should_show_product_configurator',
|
||||
type='jsonrpc',
|
||||
auth='public',
|
||||
website=True,
|
||||
readonly=True,
|
||||
)
|
||||
def website_sale_should_show_product_configurator(
|
||||
self, product_template_id, ptav_ids, is_product_configured
|
||||
):
|
||||
""" Return whether the product configurator dialog should be shown.
|
||||
|
||||
:param int product_template_id: The product being checked, as a `product.template` id.
|
||||
:param list(int) ptav_ids: The combination of the product, as a list of
|
||||
`product.template.attribute.value` ids.
|
||||
:param bool is_product_configured: Whether the product is already configured.
|
||||
:rtype: bool
|
||||
:return: Whether the product configurator dialog should be shown.
|
||||
"""
|
||||
product_template = request.env['product.template'].browse(product_template_id)
|
||||
combination = request.env['product.template.attribute.value'].browse(ptav_ids)
|
||||
single_product_variant = product_template.get_single_product_variant()
|
||||
# We can't use `single_product_variant.get('has_optional_products')` as it doesn't take
|
||||
# `combination` into account.
|
||||
has_optional_products = bool(product_template.optional_product_ids.filtered(
|
||||
lambda op: self._should_show_product(op, combination)
|
||||
))
|
||||
return (
|
||||
has_optional_products
|
||||
or not (single_product_variant.get('product_id') or is_product_configured)
|
||||
)
|
||||
|
||||
def _get_product_template(self, product_template_id):
|
||||
if request.is_frontend:
|
||||
combo_item = request.env['product.combo.item'].sudo().search([
|
||||
('product_id.product_tmpl_id.id', '=', product_template_id),
|
||||
])
|
||||
if combo_item and request.env['product.template'].sudo().search_count([
|
||||
('combo_ids', 'in', combo_item.mapped('combo_id.id')),
|
||||
('website_published', '=', True),
|
||||
]):
|
||||
return request.env['product.template'].sudo().browse(product_template_id)
|
||||
return super()._get_product_template(product_template_id)
|
||||
|
||||
@route(
|
||||
route='/website_sale/product_configurator/get_values',
|
||||
type='jsonrpc',
|
||||
auth='public',
|
||||
website=True,
|
||||
readonly=True,
|
||||
)
|
||||
def website_sale_product_configurator_get_values(self, *args, **kwargs):
|
||||
self._populate_currency_and_pricelist(kwargs)
|
||||
return super().sale_product_configurator_get_values(*args, **kwargs)
|
||||
|
||||
@route(
|
||||
route='/website_sale/product_configurator/create_product',
|
||||
type='jsonrpc',
|
||||
auth='public',
|
||||
methods=['POST'],
|
||||
website=True,
|
||||
)
|
||||
def website_sale_product_configurator_create_product(self, *args, **kwargs):
|
||||
return super().sale_product_configurator_create_product(*args, **kwargs)
|
||||
|
||||
@route(
|
||||
route='/website_sale/product_configurator/update_combination',
|
||||
type='jsonrpc',
|
||||
auth='public',
|
||||
methods=['POST'],
|
||||
website=True,
|
||||
readonly=True,
|
||||
)
|
||||
def website_sale_product_configurator_update_combination(self, *args, **kwargs):
|
||||
self._populate_currency_and_pricelist(kwargs)
|
||||
return super().sale_product_configurator_update_combination(*args, **kwargs)
|
||||
|
||||
@route(
|
||||
route='/website_sale/product_configurator/get_optional_products',
|
||||
type='jsonrpc',
|
||||
auth='public',
|
||||
website=True,
|
||||
readonly=True,
|
||||
)
|
||||
def website_sale_product_configurator_get_optional_products(self, *args, **kwargs):
|
||||
self._populate_currency_and_pricelist(kwargs)
|
||||
return super().sale_product_configurator_get_optional_products(*args, **kwargs)
|
||||
|
||||
def _get_basic_product_information(
|
||||
self, product_or_template, pricelist, combination, currency=None, date=None, **kwargs
|
||||
):
|
||||
""" Override of `sale` to append website data and apply taxes.
|
||||
|
||||
:param product.product|product.template product_or_template: The product for which to seek
|
||||
information.
|
||||
:param product.pricelist pricelist: The pricelist to use.
|
||||
:param product.template.attribute.value combination: The combination of the product.
|
||||
:param res.currency|None currency: The currency of the transaction.
|
||||
:param datetime|None date: The date of the `sale.order`, to compute the price at the right
|
||||
rate.
|
||||
:param dict kwargs: Locally unused data passed to `super`.
|
||||
:rtype: dict
|
||||
:return: A dict with the following structure:
|
||||
{
|
||||
... # fields from `super`.
|
||||
'price': float,
|
||||
'can_be_sold': bool,
|
||||
'category_name': str,
|
||||
'currency_name': str,
|
||||
'strikethrough_price': float, # if there's a strikethrough_price to display.
|
||||
}
|
||||
"""
|
||||
basic_product_information = super()._get_basic_product_information(
|
||||
product_or_template.with_context(display_default_code=not request.is_frontend),
|
||||
pricelist,
|
||||
combination,
|
||||
currency=currency,
|
||||
date=date,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
if request.is_frontend:
|
||||
has_zero_price = float_is_zero(
|
||||
basic_product_information['price'], precision_rounding=currency.rounding
|
||||
)
|
||||
basic_product_information['can_be_sold'] = not (
|
||||
request.website.prevent_zero_price_sale and has_zero_price
|
||||
)
|
||||
# Don't compute the strikethrough price if there's a custom price (i.e. if `price_info`
|
||||
# is populated).
|
||||
strikethrough_price = self._get_strikethrough_price(
|
||||
product_or_template.with_context(
|
||||
**product_or_template._get_product_price_context(combination)
|
||||
),
|
||||
currency,
|
||||
date,
|
||||
basic_product_information['price'],
|
||||
basic_product_information['pricelist_rule_id'],
|
||||
) if 'price_info' not in basic_product_information else None
|
||||
if strikethrough_price:
|
||||
basic_product_information['strikethrough_price'] = strikethrough_price
|
||||
return basic_product_information
|
||||
|
||||
def _get_ptav_price_extra(self, ptav, currency, date, product_or_template):
|
||||
""" Override of `sale` to apply taxes.
|
||||
|
||||
:param product.template.attribute.value ptav: The product template attribute value for which
|
||||
to compute the extra price.
|
||||
:param res.currency currency: The currency to compute the extra price in.
|
||||
:param datetime date: The date to compute the extra price at.
|
||||
:param product.product|product.template product_or_template: The product on which the
|
||||
product template attribute value applies.
|
||||
:rtype: float
|
||||
:return: The extra price for the product template attribute value.
|
||||
"""
|
||||
price_extra = super()._get_ptav_price_extra(ptav, currency, date, product_or_template)
|
||||
if request.is_frontend:
|
||||
return self._apply_taxes_to_price(price_extra, product_or_template, currency)
|
||||
return price_extra
|
||||
|
||||
def _get_strikethrough_price(self, product_or_template, currency, date, price, pricelist_rule_id=None):
|
||||
""" Return the strikethrough price of the product, if there is one.
|
||||
|
||||
:param product.product|product.template product_or_template: The product for which to
|
||||
compute the strikethrough price.
|
||||
:param res.currency currency: The currency to compute the strikethrough price in.
|
||||
:param datetime date: The date to compute the strikethrough price at.
|
||||
:param float price: The actual price of the product.
|
||||
:rtype: float|None
|
||||
:return: The strikethrough price of the product, if there is one.
|
||||
"""
|
||||
pricelist_rule = request.env['product.pricelist.item'].browse(pricelist_rule_id)
|
||||
|
||||
# First, try to use the base price as the strikethrough price.
|
||||
# Apply taxes before comparing it to the actual price.
|
||||
if pricelist_rule._show_discount_on_shop():
|
||||
pricelist_base_price = self._apply_taxes_to_price(
|
||||
pricelist_rule._compute_price_before_discount(
|
||||
product=product_or_template,
|
||||
quantity=1.0,
|
||||
uom=product_or_template.uom_id,
|
||||
date=date,
|
||||
currency=currency,
|
||||
),
|
||||
product_or_template,
|
||||
currency,
|
||||
)
|
||||
# Only show the base price if it's greater than the actual price.
|
||||
if currency.compare_amounts(pricelist_base_price, price) == 1:
|
||||
return pricelist_base_price
|
||||
|
||||
# Second, try to use `compare_list_price` as the strikethrough price.
|
||||
# Don't apply taxes since this price should always be displayed as is.
|
||||
if (
|
||||
request.env['res.groups']._is_feature_enabled('website_sale.group_product_price_comparison')
|
||||
and product_or_template.compare_list_price
|
||||
):
|
||||
compare_list_price = product_or_template.currency_id._convert(
|
||||
from_amount=product_or_template.compare_list_price,
|
||||
to_currency=currency,
|
||||
company=request.env.company,
|
||||
date=date,
|
||||
round=False,
|
||||
)
|
||||
# Only show `compare_list_price` if it's greater than the actual price.
|
||||
if currency.compare_amounts(compare_list_price, price) == 1:
|
||||
return compare_list_price
|
||||
return None
|
||||
|
||||
def _should_show_product(self, product_template, parent_combination):
|
||||
""" Override of `sale` to only show products that can be added to the cart.
|
||||
|
||||
:param product.template product_template: The product being checked.
|
||||
:param product.template.attribute.value parent_combination: The combination of the parent
|
||||
product.
|
||||
:rtype: bool
|
||||
:return: Whether the product should be shown in the configurator.
|
||||
"""
|
||||
should_show_product = super()._should_show_product(product_template, parent_combination)
|
||||
if request.is_frontend:
|
||||
return (
|
||||
should_show_product
|
||||
and product_template._is_add_to_cart_possible(parent_combination)
|
||||
and product_template.filtered_domain(request.website.website_domain())
|
||||
)
|
||||
return should_show_product
|
||||
|
||||
@staticmethod
|
||||
def _apply_taxes_to_price(price, product_or_template, currency):
|
||||
product_taxes = product_or_template.sudo().taxes_id._filter_taxes_by_company(
|
||||
request.env.company
|
||||
)
|
||||
if product_taxes:
|
||||
taxes = request.fiscal_position.map_tax(product_taxes)
|
||||
return request.env['product.template']._apply_taxes_to_price(
|
||||
price, currency, product_taxes, taxes, product_or_template, website=request.website
|
||||
)
|
||||
return price
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
|
||||
|
||||
from odoo.http import Controller, request, route
|
||||
from odoo.tools import consteq
|
||||
|
||||
|
||||
class ProductFeed(Controller):
|
||||
|
||||
@route(
|
||||
'/gmc.xml',
|
||||
type='http',
|
||||
auth='public',
|
||||
website=True,
|
||||
sitemap=False,
|
||||
)
|
||||
def gmc_feed(self, feed_id='', access_token=''):
|
||||
"""Serve a dynamic XML feed to synchronize the eCommerce products with Google Merchant
|
||||
Center (GMC).
|
||||
|
||||
This method generates an XML feed containing information about eCommerce products.
|
||||
The feed is configured via the `product.feed` model, allowing customization such as:
|
||||
- Localization by specifying a language or pricelist (currency).
|
||||
- Filtering products by category or categories.
|
||||
|
||||
Notes:
|
||||
- The feed is only accessible through a valid `access_token`.
|
||||
- A feed will contain at most 6000 products. If there are more than 6000 products,
|
||||
only the first 6000 will be included in the feed. This is a technical limit, but a soft
|
||||
limit of 5000 products is also enforced on the `product.feed` record.
|
||||
|
||||
See also https://support.google.com/merchants/answer/7052112 for the XML format.
|
||||
|
||||
:return: The XML feed compressed using GZIP.
|
||||
:rtype: bytes
|
||||
"""
|
||||
if not request.website.enabled_gmc_src:
|
||||
raise NotFound()
|
||||
|
||||
feed_sudo = self._find_and_check_feed_access(feed_id, access_token)
|
||||
|
||||
if feed_sudo.website_id != request.website:
|
||||
raise BadRequest("Website does not match.")
|
||||
|
||||
compressed_gmc_xml = feed_sudo._render_and_cache_compressed_gmc_feed()
|
||||
|
||||
return request.make_response(compressed_gmc_xml, [
|
||||
('Content-Type', 'application/xml; charset=utf-8'),
|
||||
('Content-Encoding', 'gzip'),
|
||||
])
|
||||
|
||||
def _find_and_check_feed_access(self, feed_id, access_token):
|
||||
"""Find the feed by its ID and validate its access token.
|
||||
|
||||
:param str feed_id: The ID of the feed to validate.
|
||||
:param str access_token: The access token associated with the feed.
|
||||
:raises BadRequest: If the feed ID cannot be converted to an integer.
|
||||
:raises NotFound: If the feed ID does not match any existing feed.
|
||||
:raises Forbidden: If the provided access token does not match the feed's access token.
|
||||
:return: The feed record if access is successfully validated, in sudo mode.
|
||||
:rtype: product.feed
|
||||
"""
|
||||
try:
|
||||
feed_id = int(feed_id)
|
||||
except ValueError:
|
||||
raise BadRequest()
|
||||
feed_sudo = request.env['product.feed'].sudo().browse(feed_id).exists()
|
||||
if not feed_sudo:
|
||||
raise NotFound()
|
||||
|
||||
if not consteq(feed_sudo.access_token, access_token):
|
||||
raise Forbidden()
|
||||
|
||||
return feed_sudo
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.exceptions import AccessError, MissingError, ValidationError
|
||||
from odoo.http import request, route
|
||||
|
||||
from odoo.addons.sale.controllers import portal as sale_portal
|
||||
from odoo.addons.website_sale.controllers.cart import Cart
|
||||
|
||||
|
||||
class CustomerPortal(sale_portal.CustomerPortal):
|
||||
|
||||
@route(
|
||||
'/my/orders/reorder',
|
||||
type='jsonrpc',
|
||||
auth='public',
|
||||
website=True,
|
||||
)
|
||||
def my_orders_reorder(self, order_id, access_token=None):
|
||||
""" Retrieve reorder content and automatically add products to the cart.
|
||||
|
||||
param int order_id: The ID of the sale order to reorder.
|
||||
param str access_token: The access token for the sale order.
|
||||
return: Details of the added products.
|
||||
rtype: dict
|
||||
"""
|
||||
try:
|
||||
sale_order = self._document_check_access('sale.order', order_id, access_token=access_token)
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my')
|
||||
|
||||
lines_to_reorder = sale_order.order_line.filtered(
|
||||
# Skip section headers, deliveries, event tickets, ...
|
||||
lambda line: line.with_user(request.env.user).sudo()._is_reorder_allowed()
|
||||
)
|
||||
|
||||
if not lines_to_reorder:
|
||||
raise ValidationError(request.env._("Nothing can be reordered in this order"))
|
||||
|
||||
Cart_controller = Cart()
|
||||
order_sudo = request.cart or request.website._create_cart()
|
||||
warnings_to_aggregate = set()
|
||||
values = {
|
||||
'tracking_info': [],
|
||||
}
|
||||
for line in lines_to_reorder:
|
||||
|
||||
linked_products = []
|
||||
if line.product_id.type == 'combo':
|
||||
for linked_line in line.linked_line_ids.filtered('combo_item_id'):
|
||||
combination = (
|
||||
linked_line.product_id.product_template_attribute_value_ids
|
||||
| linked_line.product_no_variant_attribute_value_ids
|
||||
)
|
||||
linked_products.append({
|
||||
'product_template_id': linked_line.product_id.product_tmpl_id.id,
|
||||
'product_id': linked_line.product_id.id,
|
||||
'combination': combination.ids,
|
||||
'no_variant_attribute_value_ids': linked_line.product_no_variant_attribute_value_ids.ids,
|
||||
'product_custom_attribute_values': [{
|
||||
'custom_product_template_attribute_value_id': pcav.custom_product_template_attribute_value_id.id,
|
||||
'custom_value': pcav.custom_value,
|
||||
} for pcav in linked_line.product_custom_attribute_value_ids],
|
||||
'quantity': linked_line.product_uom_qty,
|
||||
'combo_item_id': linked_line.combo_item_id.id,
|
||||
'parent_product_template_id': line.product_id.product_tmpl_id.id,
|
||||
})
|
||||
|
||||
cart_values = Cart_controller.add_to_cart(
|
||||
product_id=line.product_id.id,
|
||||
product_template_id=line.product_id.product_tmpl_id.id,
|
||||
quantity=line.product_uom_qty,
|
||||
product_custom_attribute_values=[{
|
||||
'custom_product_template_attribute_value_id': pcav.custom_product_template_attribute_value_id.id,
|
||||
'custom_value': pcav.custom_value,
|
||||
} for pcav in line.product_custom_attribute_value_ids],
|
||||
no_variant_attribute_value_ids=line.product_no_variant_attribute_value_ids.ids,
|
||||
linked_products=linked_products,
|
||||
)
|
||||
if not cart_values['quantity']:
|
||||
# Only aggregate order warnings
|
||||
warnings_to_aggregate.add(order_sudo.shop_warning)
|
||||
|
||||
values['tracking_info'].extend(cart_values['tracking_info'])
|
||||
|
||||
if warnings_to_aggregate:
|
||||
order_sudo.shop_warning = '\n'.join(warnings_to_aggregate)
|
||||
|
||||
values['cart_quantity'] = order_sudo.cart_quantity
|
||||
return values
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.addons.sale.controllers import portal as sale_portal
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class CustomerPortal(sale_portal.CustomerPortal):
|
||||
|
||||
def _get_payment_values(self, order_sudo, website_id=None, **kwargs):
|
||||
""" Override of `sale` to inject the `website_id` into the kwargs.
|
||||
|
||||
:param sale.order order_sudo: The sales order being paid.
|
||||
:param int website_id: The website on which the order was made, if any, as a `website` id.
|
||||
:param dict kwargs: Locally unused keywords arguments.
|
||||
:return: The payment-specific values.
|
||||
:rtype: dict
|
||||
"""
|
||||
if not website_id:
|
||||
if order_sudo.website_id:
|
||||
website_id = order_sudo.website_id.id
|
||||
elif request.website:
|
||||
website_id = request.website.id
|
||||
|
||||
return super()._get_payment_values(order_sudo, website_id=website_id, **kwargs)
|
||||
|
|
@ -1,33 +1,72 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details
|
||||
|
||||
from odoo.addons.sale.controllers.variant import VariantController
|
||||
from odoo.http import Controller, request, route
|
||||
|
||||
|
||||
class WebsiteSaleVariantController(VariantController):
|
||||
@http.route(['/sale/get_combination_info_website'], type='json', auth="public", methods=['POST'], website=True)
|
||||
def get_combination_info_website(self, product_template_id, product_id, combination, add_qty, **kw):
|
||||
"""Special route to use website logic in get_combination_info override.
|
||||
This route is called in JS by appending _website to the base route.
|
||||
"""
|
||||
kw.pop('pricelist_id')
|
||||
combination = self.get_combination_info(product_template_id, product_id, combination, add_qty, request.website.get_current_pricelist(), **kw)
|
||||
class WebsiteSaleVariantController(Controller):
|
||||
|
||||
if request.website.google_analytics_key:
|
||||
combination['product_tracking_info'] = request.env['product.template'].get_google_analytics_data(combination)
|
||||
@route(
|
||||
'/website_sale/get_combination_info',
|
||||
type='jsonrpc',
|
||||
auth='public',
|
||||
methods=['POST'],
|
||||
website=True,
|
||||
readonly=True,
|
||||
)
|
||||
def get_combination_info_website(
|
||||
self, product_template_id, product_id, combination, add_qty, uom_id=None, **kwargs
|
||||
):
|
||||
product_template_id = product_template_id and int(product_template_id)
|
||||
product_id = product_id and int(product_id)
|
||||
add_qty = (add_qty and float(add_qty)) or 1.0
|
||||
|
||||
product_template = request.env['product.template'].browse(product_template_id)
|
||||
|
||||
combination_info = product_template._get_combination_info(
|
||||
combination=request.env['product.template.attribute.value'].browse(combination),
|
||||
product_id=product_id,
|
||||
add_qty=add_qty,
|
||||
uom_id=uom_id,
|
||||
)
|
||||
combination_info['currency_precision'] = combination_info['currency'].decimal_places
|
||||
|
||||
for key in (
|
||||
# Only provided to ease server-side computations.
|
||||
'product_taxes', 'taxes', 'currency', 'date', 'combination',
|
||||
# Only used in Google Merchant Center logic, not client-side.
|
||||
'discount_start_date', 'discount_end_date'
|
||||
):
|
||||
combination_info.pop(key)
|
||||
|
||||
product = request.env['product.product'].browse(combination_info['product_id'])
|
||||
if product and product.id == product_id:
|
||||
combination_info['no_product_change'] = True
|
||||
return combination_info
|
||||
|
||||
if request.website.product_page_image_width != 'none' and not request.env.context.get('website_sale_no_images', False):
|
||||
carousel_view = request.env['ir.ui.view']._render_template('website_sale.shop_product_images', values={
|
||||
'product': request.env['product.template'].browse(combination['product_template_id']),
|
||||
'product_variant': request.env['product.product'].browse(combination['product_id']),
|
||||
'website': request.env['website'].get_current_website(),
|
||||
})
|
||||
combination['carousel'] = carousel_view
|
||||
return combination
|
||||
product_or_template = product or product_template
|
||||
combination_info['display_image'] = bool(product_or_template.image_128)
|
||||
combination_info['carousel'] = request.env['ir.ui.view']._render_template(
|
||||
'website_sale.shop_product_images',
|
||||
values={
|
||||
'product': product_template,
|
||||
'product_variant': product,
|
||||
'website': request.website,
|
||||
},
|
||||
)
|
||||
|
||||
@http.route(auth="public")
|
||||
if request.website.is_view_active('website_sale.product_tags'):
|
||||
all_tags = product.all_product_tag_ids if product else product_template.product_tag_ids
|
||||
combination_info['product_tags'] = request.env['ir.ui.view']._render_template(
|
||||
'website_sale.product_tags', values={
|
||||
'all_product_tags': all_tags.filtered('visible_to_customers'),
|
||||
}
|
||||
)
|
||||
return combination_info
|
||||
|
||||
@route('/sale/create_product_variant', type='jsonrpc', auth='public', methods=['POST'])
|
||||
def create_product_variant(self, product_template_id, product_template_attribute_value_ids, **kwargs):
|
||||
"""Override because on the website the public user must access it."""
|
||||
return super(WebsiteSaleVariantController, self).create_product_variant(product_template_id, product_template_attribute_value_ids, **kwargs)
|
||||
"""Old product configurator logic, only used by frontend configurator, will be deprecated soon"""
|
||||
return request.env['product.template'].browse(
|
||||
int(product_template_id)
|
||||
).create_product_variant(product_template_attribute_value_ids)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import json
|
||||
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.http import request, route
|
||||
|
||||
from odoo.addons.base.models.ir_qweb_fields import nl2br_enclose
|
||||
from odoo.addons.website.controllers import main
|
||||
from odoo.addons.website.controllers.form import WebsiteForm
|
||||
from odoo.addons.website_sale.models.website import (
|
||||
FISCAL_POSITION_SESSION_CACHE_KEY,
|
||||
PRICELIST_SESSION_CACHE_KEY,
|
||||
PRICELIST_SELECTED_SESSION_CACHE_KEY
|
||||
)
|
||||
|
||||
|
||||
class WebsiteSaleForm(WebsiteForm):
|
||||
|
||||
@route('/website/form/shop.sale.order', type='http', auth="public", methods=['POST'], website=True)
|
||||
def website_form_saleorder(self, **kwargs):
|
||||
model_record = request.env.ref('sale.model_sale_order').sudo()
|
||||
try:
|
||||
data = self.extract_data(model_record, kwargs)
|
||||
except ValidationError as e:
|
||||
return json.dumps({'error_fields': e.args[0]})
|
||||
|
||||
if not (order_sudo := request.cart):
|
||||
return json.dumps({'error': "No order found; please add a product to your cart."})
|
||||
|
||||
if data['record']:
|
||||
order_sudo.write(data['record'])
|
||||
|
||||
if data['custom']:
|
||||
order_sudo._message_log(
|
||||
body=nl2br_enclose(data['custom'], 'p'),
|
||||
message_type='comment',
|
||||
)
|
||||
|
||||
if data['attachments']:
|
||||
self.insert_attachment(model_record, order_sudo.id, data['attachments'])
|
||||
|
||||
return json.dumps({'id': order_sudo.id})
|
||||
|
||||
|
||||
class Website(main.Website):
|
||||
|
||||
def _login_redirect(self, uid, redirect=None):
|
||||
# If we are logging in, clear the current pricelist to be able to find
|
||||
# the pricelist that corresponds to the user afterwards.
|
||||
request.session.pop(PRICELIST_SESSION_CACHE_KEY, None)
|
||||
request.session.pop(FISCAL_POSITION_SESSION_CACHE_KEY, None)
|
||||
request.session.pop(PRICELIST_SELECTED_SESSION_CACHE_KEY, None)
|
||||
return super()._login_redirect(uid, redirect=redirect)
|
||||
|
||||
@route()
|
||||
def autocomplete(self, search_type=None, term=None, order=None, limit=5, max_nb_chars=999, options=None):
|
||||
options = options or {}
|
||||
if 'display_currency' not in options:
|
||||
options['display_currency'] = request.website.currency_id
|
||||
return super().autocomplete(search_type, term, order, limit, max_nb_chars, options)
|
||||
|
||||
@route()
|
||||
def theme_customize_data(self, is_view_data, enable=None, disable=None, reset_view_arch=False):
|
||||
super().theme_customize_data(is_view_data, enable, disable, reset_view_arch)
|
||||
if any(key in enable or key in disable for key in ['website_sale.products_list_view', 'website_sale.add_grid_or_list_option']):
|
||||
request.session.pop('website_sale_shop_layout_mode', None)
|
||||
|
||||
@route()
|
||||
def get_current_currency(self, **kwargs):
|
||||
return {
|
||||
'id': request.website.currency_id.id,
|
||||
'symbol': request.website.currency_id.symbol,
|
||||
'position': request.website.currency_id.position,
|
||||
}
|
||||
|
||||
@route()
|
||||
def change_lang(self, lang, **kwargs):
|
||||
if cart := request.cart:
|
||||
request.env.add_to_compute(
|
||||
cart.order_line._fields['name'],
|
||||
cart.order_line.with_context(lang=lang),
|
||||
)
|
||||
return super().change_lang(lang, **kwargs)
|
||||
Loading…
Add table
Add a link
Reference in a new issue