19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:12 +01:00
parent 79f83631d5
commit 73afc09215
6267 changed files with 1534193 additions and 1130106 deletions

View file

@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import onboarding
from . import combo_configurator
from . import portal
from . import variant
from . import product_configurator

View file

@ -0,0 +1,218 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime
from odoo.http import Controller, request, route
from odoo.tools import groupby
class SaleComboConfiguratorController(Controller):
@route(route='/sale/combo_configurator/get_data', type='jsonrpc', auth='user', readonly=True)
def sale_combo_configurator_get_data(
self,
product_tmpl_id,
quantity,
date,
currency_id=None,
company_id=None,
pricelist_id=None,
selected_combo_items=None,
**kwargs,
):
""" Return data about the specified combo product.
:param int product_tmpl_id: The product for which to get data, as a `product.template` id.
:param int quantity: The quantity of the product.
:param str date: The date to use to compute prices.
:param int|None currency_id: The currency to use to compute prices, as a `res.currency` id.
:param int|None company_id: The company to use, as a `res.company` id.
:param int|None pricelist_id: The pricelist to use to compute prices, as a
`product.pricelist` id.
:param list(dict) selected_combo_items: The selected combo items, in the following format:
{
'id': int,
'no_variant_ptav_ids': list(int),
'custom_ptavs': list({
'id': int,
'value': str,
}),
}
:param dict kwargs: Locally unused data passed to `_get_configurator_display_price` and
`_get_additional_configurator_data`.
:rtype: dict
:return: A dict containing data about the combo product.
"""
if company_id:
request.update_context(allowed_company_ids=[company_id])
product_template = request.env['product.template'].browse(product_tmpl_id)
currency = request.env['res.currency'].browse(currency_id)
pricelist = request.env['product.pricelist'].browse(pricelist_id)
date = datetime.fromisoformat(date)
selected_combo_item_dict = {item['id']: item for item in selected_combo_items or []}
return {
'product_tmpl_id': product_tmpl_id,
'display_name': product_template.display_name,
'quantity': quantity,
'price': product_template._get_configurator_display_price(
product_template, quantity, date, currency, pricelist, **kwargs
)[0],
'combos': [{
'id': combo.id,
'name': combo.name,
'combo_items': [
self._get_combo_item_data(
combo,
combo_item,
selected_combo_item_dict.get(combo_item.id, {}),
date,
currency,
pricelist,
quantity=quantity,
**kwargs,
) for combo_item in combo.combo_item_ids if combo_item.product_id.active
],
} for combo in product_template.sudo().combo_ids],
'currency_id': currency_id,
**product_template._get_additional_configurator_data(
product_template, date, currency, pricelist, quantity=quantity, **kwargs
),
}
@route(route='/sale/combo_configurator/get_price', type='jsonrpc', auth='user', readonly=True)
def sale_combo_configurator_get_price(
self,
product_tmpl_id,
quantity,
date,
currency_id=None,
company_id=None,
pricelist_id=None,
**kwargs,
):
""" Return the price of the specified combo product.
:param int product_tmpl_id: The product for which to get the price, as a `product.template`
id.
:param int quantity: The quantity of the product.
:param str date: The date to use to compute the price.
:param int|None currency_id: The currency to use to compute the price, as a `res.currency`
id.
:param int|None company_id: The company to use, as a `res.company` id.
:param int|None pricelist_id: The pricelist to use to compute the price, as a
`product.pricelist` id.
:param dict kwargs: Locally unused data passed to `_get_configurator_display_price`.
:rtype: float
:return: The price of the combo product.
"""
if company_id:
request.update_context(allowed_company_ids=[company_id])
product_template = request.env['product.template'].browse(product_tmpl_id)
currency = request.env['res.currency'].browse(currency_id)
pricelist = request.env['product.pricelist'].browse(pricelist_id)
date = datetime.fromisoformat(date)
return product_template._get_configurator_display_price(
product_template, quantity, date, currency, pricelist, **kwargs
)[0]
def _get_combo_item_data(
self, combo, combo_item, selected_combo_item, date, currency, pricelist, **kwargs
):
""" Return the price of the specified combo product.
:param product.combo combo: The combo for which to get the data.
:param product.combo.item combo_item: The combo for which to get the data.
:param datetime date: The date to use to compute prices.
:param product.pricelist pricelist: The pricelist to use to compute prices.
:param dict kwargs: Locally unused data passed to `_get_additional_configurator_data`.
:rtype: dict
:return: A dict containing data about the combo item.
"""
# A combo item is configurable if its product variant has:
# - Configurable `no_variant` PTALs,
# - Or custom PTAVs.
is_configurable = any(
ptal.attribute_id.create_variant == 'no_variant' and ptal._is_configurable()
for ptal in combo_item.product_id.attribute_line_ids
) or any(
ptav.is_custom for ptav in combo_item.product_id.product_template_attribute_value_ids
)
# A combo item can be preselected if its combo choice has only one combo item, and that
# combo item isn't configurable.
is_preselected = len(combo.combo_item_ids) == 1 and not is_configurable
return {
'id': combo_item.id,
'extra_price': combo_item.extra_price,
'is_preselected': is_preselected,
'is_selected': bool(selected_combo_item) or is_preselected,
'is_configurable': is_configurable,
'product': {
'id': combo_item.product_id.id,
'product_tmpl_id': combo_item.product_id.product_tmpl_id.id,
'display_name': combo_item.product_id.display_name,
'ptals': self._get_ptals_data(combo_item.product_id, selected_combo_item),
'description': combo_item.product_id.description_sale,
**request.env['product.template']._get_additional_configurator_data(
combo_item.product_id, date, currency, pricelist, **kwargs
),
},
}
def _get_ptals_data(self, product, selected_combo_item):
""" Return data about the PTALs of the specified product.
:param product.product product: The product for which to get the PTALs.
:param dict selected_combo_item: The selected combo item, in the following format:
{
'id': int,
'no_variant_ptav_ids': list(int),
'custom_ptavs': list({
'id': int,
'value': str,
}),
}
:rtype: list(dict)
:return: A list of dicts containing data about the specified product's PTALs.
"""
variant_ptavs = product.product_template_attribute_value_ids
no_variant_ptavs = request.env['product.template.attribute.value'].browse(
selected_combo_item.get('no_variant_ptav_ids')
)
preselected_ptavs = product.attribute_line_ids.filtered(
lambda ptal: not ptal._is_configurable()
).product_template_value_ids
ptavs_by_ptal_id = dict(groupby(
variant_ptavs | no_variant_ptavs | preselected_ptavs,
lambda ptav: ptav.attribute_line_id.id,
))
custom_ptavs = selected_combo_item.get('custom_ptavs', [])
custom_value_by_ptav_id = {ptav['id']: ptav['value'] for ptav in custom_ptavs}
return [{
'id': ptal.id,
'name': ptal.attribute_id.name,
'create_variant': ptal.attribute_id.create_variant,
'selected_ptavs': self._get_selected_ptavs_data(
ptavs_by_ptal_id.get(ptal.id, []), custom_value_by_ptav_id
),
} for ptal in product.attribute_line_ids]
def _get_selected_ptavs_data(self, selected_ptavs, custom_value_by_ptav_id):
""" Return data about the selected PTAVs of the specified product.
:param list(product.template.attribute.value) selected_ptavs: The selected PTAVs.
:param dict custom_value_by_ptav_id: A mapping from PTAV ids to custom values.
:rtype: list(dict)
:return: A list of dicts containing data about the specified PTAL's selected PTAVs.
"""
return [{
'id': ptav.id,
'name': ptav.name,
'price_extra': ptav.price_extra,
'custom_value': custom_value_by_ptav_id.get(ptav.id),
} for ptav in selected_ptavs]

View file

@ -1,26 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import http
from odoo.http import request
class OnboardingController(http.Controller):
@http.route('/sales/sale_quotation_onboarding_panel', auth='user', type='json')
def sale_quotation_onboarding(self):
""" Returns the `banner` for the sale onboarding panel.
It can be empty if the user has closed it or if he doesn't have
the permission to see it. """
company = request.env.company
if not request.env.is_admin() or \
company.sale_quotation_onboarding_state == 'closed':
return {}
return {
'html': request.env['ir.qweb']._render('sale.sale_quotation_onboarding_panel', {
'company': company,
'state': company.get_and_update_sale_quotation_onboarding_state()
})
}

View file

@ -1,21 +1,18 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import binascii
from odoo import fields, http, SUPERUSER_ID, _
from odoo import SUPERUSER_ID, _, fields, http
from odoo.exceptions import AccessError, MissingError, ValidationError
from odoo.fields import Command
from odoo.http import request
from odoo.addons.payment.controllers import portal as payment_portal
from odoo.addons.payment import utils as payment_utils
from odoo.addons.portal.controllers.mail import _message_post_helper
from odoo.addons.portal.controllers import portal
from odoo.addons.payment.controllers import portal as payment_portal
from odoo.addons.portal.controllers.portal import pager as portal_pager
class CustomerPortal(portal.CustomerPortal):
class CustomerPortal(payment_portal.PaymentPortal):
def _prepare_home_portal_values(self, counters):
values = super()._prepare_home_portal_values(counters)
@ -24,30 +21,28 @@ class CustomerPortal(portal.CustomerPortal):
SaleOrder = request.env['sale.order']
if 'quotation_count' in counters:
values['quotation_count'] = SaleOrder.search_count(self._prepare_quotations_domain(partner)) \
if SaleOrder.check_access_rights('read', raise_exception=False) else 0
if SaleOrder.has_access('read') else 0
if 'order_count' in counters:
values['order_count'] = SaleOrder.search_count(self._prepare_orders_domain(partner)) \
if SaleOrder.check_access_rights('read', raise_exception=False) else 0
values['order_count'] = SaleOrder.search_count(self._prepare_orders_domain(partner), limit=1) \
if SaleOrder.has_access('read') else 0
return values
def _prepare_quotations_domain(self, partner):
return [
('message_partner_ids', 'child_of', [partner.commercial_partner_id.id]),
('state', 'in', ['sent', 'cancel'])
('partner_id', 'child_of', [partner.commercial_partner_id.id]),
('state', '=', 'sent')
]
def _prepare_orders_domain(self, partner):
return [
('message_partner_ids', 'child_of', [partner.commercial_partner_id.id]),
('state', 'in', ['sale', 'done'])
('partner_id', 'child_of', [partner.commercial_partner_id.id]),
('state', '=', 'sale'),
]
def _get_sale_searchbar_sortings(self):
return {
'date': {'label': _('Order Date'), 'order': 'date_order desc'},
'name': {'label': _('Reference'), 'order': 'name'},
'stage': {'label': _('Stage'), 'order': 'state'},
}
def _prepare_sale_portal_rendering_values(
@ -75,14 +70,19 @@ class CustomerPortal(portal.CustomerPortal):
if date_begin and date_end:
domain += [('create_date', '>', date_begin), ('create_date', '<=', date_end)]
url_args = {'date_begin': date_begin, 'date_end': date_end}
if len(searchbar_sortings) > 1:
url_args['sortby'] = sortby
pager_values = portal_pager(
url=url,
total=SaleOrder.search_count(domain),
total=SaleOrder.search_count(domain) if SaleOrder.has_access('read') else 0,
page=page,
step=self._items_per_page,
url_args={'date_begin': date_begin, 'date_end': date_end, 'sortby': sortby},
url_args=url_args,
)
orders = SaleOrder.search(domain, order=sort_order, limit=self._items_per_page, offset=pager_values['offset'])
orders = SaleOrder.search(domain, order=sort_order, limit=self._items_per_page, offset=pager_values['offset']) if SaleOrder.has_access('read') else SaleOrder
values.update({
'date': date_begin,
@ -91,12 +91,18 @@ class CustomerPortal(portal.CustomerPortal):
'page_name': 'quote' if quotation_page else 'order',
'pager': pager_values,
'default_url': url,
'searchbar_sortings': searchbar_sortings,
'sortby': sortby,
})
if len(searchbar_sortings) > 1:
values.update({
'sortby': sortby,
'searchbar_sortings': searchbar_sortings,
})
return values
# Two following routes cannot be readonly because of the call to `_portal_ensure_token` on all
# displayed orders, to assign an access token (triggering a sql update on flush)
@http.route(['/my/quotes', '/my/quotes/page/<int:page>'], type='http', auth="user", website=True)
def portal_my_quotes(self, **kwargs):
values = self._prepare_sale_portal_rendering_values(quotation_page=True, **kwargs)
@ -110,16 +116,38 @@ class CustomerPortal(portal.CustomerPortal):
return request.render("sale.portal_my_orders", values)
@http.route(['/my/orders/<int:order_id>'], type='http', auth="public", website=True)
def portal_order_page(self, order_id, report_type=None, access_token=None, message=False, download=False, **kw):
def portal_order_page(
self,
order_id,
report_type=None,
access_token=None,
message=False,
download=False,
payment_amount=None,
amount_selection=None,
**kw
):
try:
order_sudo = self._document_check_access('sale.order', order_id, access_token=access_token)
except (AccessError, MissingError):
return request.redirect('/my')
if report_type in ('html', 'pdf', 'text'):
return self._show_report(model=order_sudo, report_type=report_type, report_ref='sale.action_report_saleorder', download=download)
payment_amount = self._cast_as_float(payment_amount)
prepayment_amount = order_sudo._get_prepayment_required_amount()
if payment_amount and payment_amount < prepayment_amount and order_sudo.state != 'sale':
raise MissingError(_("The amount is lower than the prepayment amount."))
if request.env.user.share and access_token:
if report_type in ('html', 'pdf', 'text'):
return self._show_report(
model=order_sudo,
report_type=report_type,
report_ref='sale.action_report_saleorder',
download=download,
)
# If the route is fetched from the link previewer avoid triggering that quotation is viewed.
is_link_preview = request.httprequest.headers.get('Odoo-Link-Preview')
if request.env.user.share and access_token and is_link_preview != 'True':
# If a public/portal user accesses the order with the access token
# Log a note on the chatter.
today = fields.Date.today().isoformat()
@ -130,33 +158,37 @@ class CustomerPortal(portal.CustomerPortal):
# The "Quotation viewed by customer" log note is an information
# dedicated to the salesman and shouldn't be translated in the customer/website lgg
context = {'lang': order_sudo.user_id.partner_id.lang or order_sudo.company_id.partner_id.lang}
msg = _('Quotation viewed by customer %s', order_sudo.partner_id.name if request.env.user._is_public() else request.env.user.partner_id.name)
author = order_sudo.partner_id if request.env.user._is_public() else request.env.user.partner_id
msg = _('Quotation viewed by customer %s', author.name)
del context
_message_post_helper(
"sale.order",
order_sudo.id,
message=msg,
token=order_sudo.access_token,
order_sudo.with_user(SUPERUSER_ID).message_post(
body=msg,
message_type="notification",
subtype_xmlid="mail.mt_note",
partner_ids=order_sudo.user_id.sudo().partner_id.ids,
subtype_xmlid="sale.mt_order_viewed",
)
backend_url = f'/web#model={order_sudo._name}'\
f'&id={order_sudo.id}'\
f'&action={order_sudo._get_portal_return_action().id}'\
f'&view_type=form'
backend_url = f'/odoo/action-{order_sudo._get_portal_return_action().id}/{order_sudo.id}'
values = {
'sale_order': order_sudo,
'product_documents': order_sudo._get_product_documents(),
'message': message,
'report_type': 'html',
'backend_url': backend_url,
'res_company': order_sudo.company_id, # Used to display correct company logo
'payment_amount': payment_amount,
}
# Payment values
if order_sudo._has_to_be_paid():
values.update(self._get_payment_values(order_sudo))
if order_sudo._has_to_be_paid() or (payment_amount and not order_sudo.is_expired):
values.update(self._get_payment_values(
order_sudo,
is_down_payment=self._determine_is_down_payment(
order_sudo, amount_selection, payment_amount
),
payment_amount=payment_amount,
))
else:
values['payment_amount'] = None
if order_sudo.state in ('draft', 'sent', 'cancel'):
history_session_key = 'my_quotations_history'
@ -164,58 +196,114 @@ class CustomerPortal(portal.CustomerPortal):
history_session_key = 'my_orders_history'
values = self._get_page_view_values(
order_sudo, access_token, values, history_session_key, False)
order_sudo, access_token, values, history_session_key, False, **kw)
return request.render('sale.sale_order_portal_template', values)
def _get_payment_values(self, order_sudo):
def _determine_is_down_payment(self, order_sudo, amount_selection, payment_amount):
""" Determine whether the current payment is a down payment.
:param sale.order order_sudo: The sales order being paid.
:param str amount_selection: The amount selection specified in the payment link.
:param float payment_amount: The amount suggested in the payment link.
:return: Whether the current payment is a down payment.
:rtype: bool
"""
if amount_selection == 'down_payment': # The customer chose to pay a down payment.
is_down_payment = True
elif amount_selection == 'full_amount': # The customer chose to pay the full amount.
is_down_payment = False
else: # No choice has been specified yet.
is_down_payment = (
order_sudo.prepayment_percent < 1.0 if payment_amount is None
else payment_amount < order_sudo.amount_total
)
return is_down_payment
def _get_payment_values(self, order_sudo, is_down_payment=False, payment_amount=None, **kwargs):
""" Return the payment-specific QWeb context values.
:param recordset order_sudo: The sales order being paid, as a `sale.order` record.
:param sale.order order_sudo: The sales order being paid.
:param bool is_down_payment: Whether the current payment is a down payment.
:param float payment_amount: The amount suggested in the payment link.
:param dict kwargs: Locally unused data passed to `_get_compatible_providers` and
`_get_available_tokens`.
:return: The payment-specific values.
:rtype: dict
"""
company = order_sudo.company_id
logged_in = not request.env.user._is_public()
partner_sudo = request.env.user.partner_id if logged_in else order_sudo.partner_id
currency = order_sudo.currency_id
if is_down_payment:
if payment_amount and payment_amount < order_sudo.amount_total:
amount = payment_amount
else:
amount = order_sudo._get_prepayment_required_amount()
elif order_sudo.state == 'sale':
amount = payment_amount or order_sudo.amount_total
else:
amount = order_sudo.amount_total
availability_report = {}
# Select all the payment methods and tokens that match the payment context.
providers_sudo = request.env['payment.provider'].sudo()._get_compatible_providers(
order_sudo.company_id.id,
order_sudo.partner_id.id,
order_sudo.amount_total,
currency_id=order_sudo.currency_id.id,
company.id,
partner_sudo.id,
amount,
currency_id=currency.id,
sale_order_id=order_sudo.id,
) # In sudo mode to read the fields of providers and partner (if not logged in)
tokens = request.env['payment.token'].search([
('provider_id', 'in', providers_sudo.ids),
('partner_id', '=', order_sudo.partner_id.id)
]) if logged_in else request.env['payment.token']
# Make sure that the partner's company matches the order's company.
if not payment_portal.PaymentPortal._can_partner_pay_in_company(
order_sudo.partner_id, order_sudo.company_id
):
providers_sudo = request.env['payment.provider'].sudo()
tokens = request.env['payment.token']
fees_by_provider = {
provider: provider._compute_fees(
order_sudo.amount_total,
order_sudo.currency_id,
order_sudo.partner_id.country_id,
) for provider in providers_sudo.filtered('fees_active')
report=availability_report,
**kwargs,
) # In sudo mode to read the fields of providers and partner (if logged out).
payment_methods_sudo = request.env['payment.method'].sudo()._get_compatible_payment_methods(
providers_sudo.ids,
partner_sudo.id,
currency_id=currency.id,
sale_order_id=order_sudo.id,
report=availability_report,
**kwargs,
) # In sudo mode to read the fields of providers.
tokens_sudo = request.env['payment.token'].sudo()._get_available_tokens(
providers_sudo.ids, partner_sudo.id, **kwargs
) # In sudo mode to read the partner's tokens (if logged out) and provider fields.
# Make sure that the partner's company matches the invoice's company.
company_mismatch = not payment_portal.PaymentPortal._can_partner_pay_in_company(
partner_sudo, company
)
portal_page_values = {
'company_mismatch': company_mismatch,
'expected_company': company,
'payment_amount': payment_amount,
}
return {
'providers': providers_sudo,
'tokens': tokens,
'fees_by_provider': fees_by_provider,
'show_tokenize_input': PaymentPortal._compute_show_tokenize_input_mapping(
providers_sudo, logged_in=logged_in, sale_order_id=order_sudo.id
payment_form_values = {
'show_tokenize_input_mapping': PaymentPortal._compute_show_tokenize_input_mapping(
providers_sudo, sale_order_id=order_sudo.id
),
'amount': order_sudo.amount_total,
'currency': order_sudo.pricelist_id.currency_id,
'partner_id': order_sudo.partner_id.id,
'access_token': order_sudo.access_token,
}
payment_context = {
'amount': amount,
'currency': currency,
'partner_id': partner_sudo.id,
'providers_sudo': providers_sudo,
'payment_methods_sudo': payment_methods_sudo,
'tokens_sudo': tokens_sudo,
'availability_report': availability_report,
'transaction_route': order_sudo.get_portal_url(suffix='/transaction'),
'landing_route': order_sudo.get_portal_url(),
'access_token': order_sudo._portal_ensure_token(),
}
return {
**portal_page_values,
**payment_form_values,
**payment_context,
**self._get_extra_payment_form_values(**kwargs),
}
@http.route(['/my/orders/<int:order_id>/accept'], type='json', auth="public", website=True)
@http.route(['/my/orders/<int:order_id>/accept'], type='jsonrpc', auth="public", website=True)
def portal_quote_accept(self, order_id, access_token=None, name=None, signature=None):
# get from query string if not on json param
access_token = access_token or request.httprequest.args.get('access_token')
@ -235,27 +323,31 @@ class CustomerPortal(portal.CustomerPortal):
'signed_on': fields.Datetime.now(),
'signature': signature,
})
request.env.cr.commit()
# flush now to make signature data available to PDF render request
request.env.cr.flush()
except (TypeError, binascii.Error) as e:
return {'error': _('Invalid signature data.')}
if not order_sudo._has_to_be_paid():
order_sudo.action_confirm()
order_sudo._send_order_confirmation_mail()
order_sudo._validate_order()
pdf = request.env['ir.actions.report'].sudo()._render_qweb_pdf('sale.action_report_saleorder', [order_sudo.id])[0]
_message_post_helper(
'sale.order',
order_sudo.id,
_('Order signed by %s', name),
order_sudo.message_post(
attachments=[('%s.pdf' % order_sudo.name, pdf)],
token=access_token,
author_id=(
order_sudo.partner_id.id
if request.env.user._is_public()
else request.env.user.partner_id.id
),
body=_('Order signed by %s', name),
message_type='comment',
subtype_xmlid='mail.mt_comment',
)
query_string = '&message=sign_ok'
if order_sudo._has_to_be_paid(True):
query_string += '#allow_payment=yes'
if order_sudo._has_to_be_paid():
query_string += '&allow_payment=yes'
return {
'force_refresh': True,
'redirect_url': order_sudo.get_portal_url(query_string=query_string),
@ -270,11 +362,22 @@ class CustomerPortal(portal.CustomerPortal):
if order_sudo._has_to_be_signed() and decline_message:
order_sudo._action_cancel()
_message_post_helper(
'sale.order',
order_sudo.id,
decline_message,
token=access_token,
# The currency is manually cached while in a sudoed environment to prevent an
# AccessError. The state of the Sales Order is a dependency of
# `untaxed_amount_to_invoice`, which is a monetary field. They require the currency to
# ensure the values are saved in the correct format. However, the currency cannot be
# read directly during the flush due to access rights, necessitating manual caching.
order_sudo.order_line.currency_id
order_sudo.message_post(
author_id=(
order_sudo.partner_id.id
if request.env.user._is_public()
else request.env.user.partner_id.id
),
body=decline_message,
message_type='comment',
subtype_xmlid='mail.mt_comment',
)
redirect_url = order_sudo.get_portal_url()
else:
@ -282,10 +385,55 @@ class CustomerPortal(portal.CustomerPortal):
return request.redirect(redirect_url)
@http.route('/my/orders/<int:order_id>/document/<int:document_id>', type='http', auth='public', readonly=True)
def portal_quote_document(self, order_id, document_id, access_token):
try:
order_sudo = self._document_check_access('sale.order', order_id, access_token=access_token)
except (AccessError, MissingError):
return request.redirect('/my')
document = request.env['product.document'].browse(document_id).sudo().exists()
if not document or not document.active:
return request.redirect('/my')
if document not in order_sudo._get_product_documents():
return request.redirect('/my')
return request.env['ir.binary']._get_stream_from(
document.ir_attachment_id,
).get_response(as_attachment=True)
@http.route(['/my/orders/<int:order_id>/download_edi'], auth="public", website=True)
def portal_my_sale_order_download_edi(self, order_id=None, access_token=None, **kw):
""" An endpoint to download EDI file representation."""
try:
order_sudo = self._document_check_access('sale.order', order_id, access_token=access_token)
except (AccessError, MissingError):
return request.redirect('/my')
builders = order_sudo._get_edi_builders()
# This handles only one builder for now, more can be added in the future
# TODO: add builder choice on modal
if len(builders) == 0:
return request.redirect('/my')
builder = builders[0]
xml_content = builder._export_order(order_sudo)
download_name = builder._export_invoice_filename(order_sudo) # works even if it's a SO or PO
http_headers = [
('Content-Type', 'text/xml'),
('Content-Length', len(xml_content)),
('Content-Disposition', f'attachment; filename={download_name}')
]
return request.make_response(xml_content, headers=http_headers)
class PaymentPortal(payment_portal.PaymentPortal):
@http.route('/my/orders/<int:order_id>/transaction', type='json', auth='public')
@http.route('/my/orders/<int:order_id>/transaction', type='jsonrpc', auth='public')
def portal_order_transaction(self, order_id, access_token, **kwargs):
""" Create a draft transaction and return its processing values.
@ -304,92 +452,16 @@ class PaymentPortal(payment_portal.PaymentPortal):
except AccessError:
raise ValidationError(_("The access token is invalid."))
logged_in = not request.env.user._is_public()
partner_sudo = request.env.user.partner_id if logged_in else order_sudo.partner_invoice_id
self._validate_transaction_kwargs(kwargs)
kwargs.update({
'reference_prefix': None, # Allow the reference to be computed based on the order
'partner_id': order_sudo.partner_invoice_id.id,
'partner_id': partner_sudo.id,
'currency_id': order_sudo.currency_id.id,
'sale_order_id': order_id, # Include the SO to allow Subscriptions tokenizing the tx
})
kwargs.pop('custom_create_values', None) # Don't allow passing arbitrary create values
tx_sudo = self._create_transaction(
custom_create_values={'sale_order_ids': [Command.set([order_id])]}, **kwargs,
)
return tx_sudo._get_processing_values()
# Payment overrides
@http.route()
def payment_pay(self, *args, amount=None, sale_order_id=None, access_token=None, **kwargs):
""" Override of payment to replace the missing transaction values by that of the sale order.
This is necessary for the reconciliation as all transaction values, excepted the amount,
need to match exactly that of the sale order.
:param str amount: The (possibly partial) amount to pay used to check the access token
:param str sale_order_id: The sale order for which a payment id made, as a `sale.order` id
:param str access_token: The access token used to authenticate the partner
:return: The result of the parent method
:rtype: str
:raise: ValidationError if the order id is invalid
"""
# Cast numeric parameters as int or float and void them if their str value is malformed
amount = self._cast_as_float(amount)
sale_order_id = self._cast_as_int(sale_order_id)
if sale_order_id:
order_sudo = request.env['sale.order'].sudo().browse(sale_order_id).exists()
if not order_sudo:
raise ValidationError(_("The provided parameters are invalid."))
# Check the access token against the order values. Done after fetching the order as we
# need the order fields to check the access token.
if not payment_utils.check_access_token(
access_token, order_sudo.partner_invoice_id.id, amount, order_sudo.currency_id.id
):
raise ValidationError(_("The provided parameters are invalid."))
kwargs.update({
'currency_id': order_sudo.currency_id.id,
'partner_id': order_sudo.partner_invoice_id.id,
'company_id': order_sudo.company_id.id,
'sale_order_id': sale_order_id,
})
return super().payment_pay(*args, amount=amount, access_token=access_token, **kwargs)
def _get_custom_rendering_context_values(self, sale_order_id=None, **kwargs):
""" Override of payment to add the sale order id in the custom rendering context values.
:param int sale_order_id: The sale order for which a payment id made, as a `sale.order` id
:return: The extended rendering context values
:rtype: dict
"""
rendering_context_values = super()._get_custom_rendering_context_values(
sale_order_id=sale_order_id, **kwargs
)
if sale_order_id:
rendering_context_values['sale_order_id'] = sale_order_id
# Interrupt the payment flow if the sales order has been canceled.
order_sudo = request.env['sale.order'].sudo().browse(sale_order_id)
if order_sudo.state == 'cancel':
rendering_context_values['amount'] = 0.0
return rendering_context_values
def _create_transaction(self, *args, sale_order_id=None, custom_create_values=None, **kwargs):
""" Override of payment to add the sale order id in the custom create values.
:param int sale_order_id: The sale order for which a payment id made, as a `sale.order` id
:param dict custom_create_values: Additional create values overwriting the default ones
:return: The result of the parent method
:rtype: recordset of `payment.transaction`
"""
if sale_order_id:
if custom_create_values is None:
custom_create_values = {}
# As this override is also called if the flow is initiated from sale or website_sale, we
# need not to override whatever value these modules could have already set
if 'sale_order_ids' not in custom_create_values: # We are in the payment module's flow
custom_create_values['sale_order_ids'] = [Command.set([int(sale_order_id)])]
return super()._create_transaction(
*args, sale_order_id=sale_order_id, custom_create_values=custom_create_values, **kwargs
)

View file

@ -0,0 +1,437 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime
from odoo.http import Controller, request, route
class SaleProductConfiguratorController(Controller):
@route(route='/sale/product_configurator/get_values', type='jsonrpc', auth='user', readonly=True)
def sale_product_configurator_get_values(
self,
product_template_id,
quantity,
currency_id,
so_date,
product_uom_id=None,
company_id=None,
pricelist_id=None,
ptav_ids=None,
only_main_product=False,
**kwargs,
):
"""Return all product information needed for the product configurator.
:param int product_template_id: The product for which to seek information, as a
`product.template` id.
:param int quantity: The quantity of the product.
:param int currency_id: The currency of the transaction, as a `res.currency` id.
:param str so_date: The date of the `sale.order`, to compute the price at the right rate.
:param int|None product_uom_id: The unit of measure of the product, as a `uom.uom` id.
:param int|None company_id: The company to use, as a `res.company` id.
:param int|None pricelist_id: The pricelist to use, as a `product.pricelist` id.
:param list(int)|None ptav_ids: The combination of the product, as a list of
`product.template.attribute.value` ids.
:param bool only_main_product: Whether the optional products should be included or not.
:param dict kwargs: Locally unused data passed to `_get_product_information`.
:rtype: dict
:return: A dict containing a list of products and a list of optional products information,
generated by :meth:`_get_product_information`.
"""
if company_id:
request.update_context(allowed_company_ids=[company_id])
product_template = self._get_product_template(product_template_id)
combination = request.env['product.template.attribute.value']
if ptav_ids:
combination = request.env['product.template.attribute.value'].browse(ptav_ids).filtered(
lambda ptav: ptav.product_tmpl_id.id == product_template_id
)
# Set missing attributes (unsaved no_variant attributes, or new attribute on existing product)
unconfigured_ptals = (
product_template.attribute_line_ids - combination.attribute_line_id).filtered(
lambda ptal: ptal.attribute_id.display_type != 'multi')
combination += unconfigured_ptals.mapped(
lambda ptal: ptal.product_template_value_ids._only_active()[:1]
)
if not combination:
combination = product_template._get_first_possible_combination()
currency = request.env['res.currency'].browse(currency_id)
pricelist = request.env['product.pricelist'].browse(pricelist_id)
so_date = datetime.fromisoformat(so_date)
return {
'products': [
dict(
**self._get_product_information(
product_template,
combination,
currency,
pricelist,
so_date,
quantity=quantity,
product_uom_id=product_uom_id,
**kwargs,
),
)
],
'optional_products': [
dict(
**self._get_product_information(
optional_product_template,
optional_product_template._get_first_possible_combination(
parent_combination=combination
),
currency,
pricelist,
so_date,
# giving all the ptav of the parent product to get all the exclusions
parent_combination=product_template.attribute_line_ids.\
product_template_value_ids,
**kwargs,
),
parent_product_tmpl_id=product_template.id,
) for optional_product_template in product_template.optional_product_ids if
self._should_show_product(optional_product_template, combination)
] if not only_main_product else [],
'currency_id': currency_id,
}
@route(
route='/sale/product_configurator/create_product',
type='jsonrpc',
auth='user',
methods=['POST'],
)
def sale_product_configurator_create_product(self, product_template_id, ptav_ids):
"""Create the product when there is a dynamic attribute in the combination.
:param int product_template_id: The product for which to seek information, as a
`product.template` id.
:param list(int) ptav_ids: The combination of the product, as a list of
`product.template.attribute.value` ids.
:rtype: int
:return: The product created, as a `product.product` id.
"""
product_template = self._get_product_template(product_template_id)
combination = request.env['product.template.attribute.value'].browse(ptav_ids)
product = product_template._create_product_variant(combination)
return product.id
@route(
route='/sale/product_configurator/update_combination',
type='jsonrpc',
auth='user',
methods=['POST'],
readonly=True,
)
def sale_product_configurator_update_combination(
self,
product_template_id,
ptav_ids,
currency_id,
so_date,
quantity,
product_uom_id=None,
company_id=None,
pricelist_id=None,
**kwargs,
):
"""Return the updated combination information.
:param int product_template_id: The product for which to seek information, 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 int currency_id: The currency of the transaction, as a `res.currency` id.
:param str so_date: The date of the `sale.order`, to compute the price at the right rate.
:param int quantity: The quantity of the product.
:param int|None product_uom_id: The unit of measure of the product, as a `uom.uom` id.
:param int|None company_id: The company to use, as a `res.company` id.
:param int|None pricelist_id: The pricelist to use, as a `product.pricelist` id.
:param dict kwargs: Locally unused data passed to `_get_basic_product_information`.
:rtype: dict
:return: Basic informations about a product, generated by
:meth:`_get_basic_product_information`.
"""
if company_id:
request.update_context(allowed_company_ids=[company_id])
product_template = self._get_product_template(product_template_id)
pricelist = request.env['product.pricelist'].browse(pricelist_id)
product_uom = request.env['uom.uom'].browse(product_uom_id)
currency = request.env['res.currency'].browse(currency_id)
combination = request.env['product.template.attribute.value'].browse(ptav_ids)
product = product_template._get_variant_for_combination(combination)
values = self._get_basic_product_information(
product or product_template,
pricelist,
combination,
quantity=quantity or 0.0,
uom=product_uom,
currency=currency,
date=datetime.fromisoformat(so_date),
**kwargs,
)
# Shouldn't be sent client-side
values.pop('pricelist_rule_id', None)
return values
@route(
route='/sale/product_configurator/get_optional_products',
type='jsonrpc',
auth='user',
readonly=True,
)
def sale_product_configurator_get_optional_products(
self,
product_template_id,
ptav_ids,
parent_ptav_ids,
currency_id,
so_date,
company_id=None,
pricelist_id=None,
**kwargs,
):
"""Return information about optional products for the given `product.template`.
:param int product_template_id: The product for which to seek information, 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 list(int) parent_ptav_ids: The combination of the parent product, as a list of
`product.template.attribute.value` ids.
:param int currency_id: The currency of the transaction, as a `res.currency` id.
:param str so_date: The date of the `sale.order`, to compute the price at the right rate.
:param int|None company_id: The company to use, as a `res.company` id.
:param int|None pricelist_id: The pricelist to use, as a `product.pricelist` id.
:param dict kwargs: Locally unused data passed to `_get_product_information`.
:rtype: [dict]
:return: A list of optional products information, generated by
:meth:`_get_product_information`.
"""
if company_id:
request.update_context(allowed_company_ids=[company_id])
product_template = self._get_product_template(product_template_id)
parent_combination = request.env['product.template.attribute.value'].browse(
parent_ptav_ids + ptav_ids
)
currency = request.env['res.currency'].browse(currency_id)
pricelist = request.env['product.pricelist'].browse(pricelist_id)
return [
dict(
**self._get_product_information(
optional_product_template,
optional_product_template._get_first_possible_combination(
parent_combination=parent_combination
),
currency,
pricelist,
datetime.fromisoformat(so_date),
parent_combination=parent_combination,
**kwargs,
),
parent_product_tmpl_id=product_template.id,
) for optional_product_template in product_template.optional_product_ids if
self._should_show_product(optional_product_template, parent_combination)
]
def _get_product_template(self, product_template_id):
return request.env['product.template'].browse(product_template_id)
def _get_product_information(
self,
product_template,
combination,
currency,
pricelist,
so_date,
quantity=1,
product_uom_id=None,
parent_combination=None,
show_packaging=True,
**kwargs,
):
"""Return complete information about a product.
:param product.template product_template: The product for which to seek information.
:param product.template.attribute.value combination: The combination of the product.
:param res.currency currency: The currency of the transaction.
:param product.pricelist pricelist: The pricelist to use.
:param datetime so_date: The date of the `sale.order`, to compute the price at the right
rate.
:param int quantity: The quantity of the product.
:param int|None product_uom_id: The unit of measure of the product, as a `uom.uom` id.
:param product.template.attribute.value|None parent_combination: The combination of the
parent product.
:param dict kwargs: Locally unused data passed to `_get_basic_product_information`.
:rtype: dict
:return: A dict with the following structure:
{
'product_tmpl_id': int,
'id': int,
'description_sale': str|False,
'display_name': str,
'price': float,
'quantity': int
'attribute_line': [{
'id': int
'attribute': {
'id': int
'name': str
'display_type': str
},
'attribute_value': [{
'id': int,
'name': str,
'price_extra': float,
'html_color': str|False,
'image': str|False,
'is_custom': bool
}],
'selected_attribute_id': int,
}],
'exclusions': dict,
'archived_combination': dict,
'parent_exclusions': dict,
'available_uoms': dict (optional),
}
"""
uom = (
(product_uom_id and request.env['uom.uom'].browse(product_uom_id))
or product_template.uom_id
)
product = product_template._get_variant_for_combination(combination)
attribute_exclusions = product_template._get_attribute_exclusions(
parent_combination=parent_combination,
combination_ids=combination.ids,
)
product_or_template = product or product_template
ptals = product_template.attribute_line_ids
attrs_map = {
attr_data['id']: attr_data
for attr_data in ptals.attribute_id.read(['id', 'name', 'display_type'])
}
ptavs = ptals.product_template_value_ids.filtered(lambda p: p.ptav_active or combination and p.id in combination.ids)
ptavs_map = dict(zip(ptavs.ids, ptavs.read(['name', 'html_color', 'image', 'is_custom'])))
values = dict(
product_tmpl_id=product_template.id,
**self._get_basic_product_information(
product_or_template,
pricelist,
combination,
quantity=quantity,
uom=uom,
currency=currency,
date=so_date,
**kwargs,
),
quantity=quantity,
uom=uom.read(['id', 'display_name'])[0],
attribute_lines=[{
'id': ptal.id,
'attribute': dict(**attrs_map[ptal.attribute_id.id]),
'attribute_values': [
dict(
**ptavs_map[ptav.id],
price_extra=self._get_ptav_price_extra(
ptav, currency, so_date, product_or_template
),
) for ptav in ptal.product_template_value_ids
if ptav.ptav_active or (combination and ptav.id in combination.ids)
],
'selected_attribute_value_ids': combination.filtered(
lambda c: ptal in c.attribute_line_id
).ids,
'create_variant': ptal.attribute_id.create_variant,
} for ptal in product_template.attribute_line_ids],
exclusions=attribute_exclusions['exclusions'],
archived_combinations=attribute_exclusions['archived_combinations'],
parent_exclusions=attribute_exclusions['parent_exclusions'],
)
if show_packaging and product_template._has_multiple_uoms():
values['available_uoms'] = product_template._get_available_uoms().read(
['id', 'display_name']
)
# Shouldn't be sent client-side
values.pop('pricelist_rule_id', None)
return values
def _get_basic_product_information(self, product_or_template, pricelist, combination, **kwargs):
"""Return basic information about a product.
: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 dict kwargs: Locally unused data passed to `_get_product_price`.
:rtype: dict
:return: A dict with the following structure:
{
'id': int, # if product_or_template is a record of `product.product`.
'description_sale': str|False,
'display_name': str,
'price': float,
}
"""
basic_information = dict(
**product_or_template.read(['description_sale', 'display_name'])[0]
)
# If the product is a template, check the combination to compute the name to take dynamic
# and no_variant attributes into account. Also, drop the id which was auto-included by the
# search but isn't relevant since it is supposed to be the id of a `product.product` record.
if not product_or_template.is_product_variant:
basic_information['id'] = False
combination_name = combination._get_combination_name()
if combination_name:
basic_information.update(
display_name=f"{basic_information['display_name']} ({combination_name})"
)
price, pricelist_rule_id = request.env['product.template']._get_configurator_display_price(
product_or_template.with_context(
**product_or_template._get_product_price_context(combination)
),
pricelist=pricelist,
**kwargs,
)
return dict(
**basic_information,
price=price,
pricelist_rule_id=pricelist_rule_id,
**request.env['product.template']._get_additional_configurator_data(
product_or_template, pricelist=pricelist, **kwargs
),
)
def _get_ptav_price_extra(self, ptav, currency, date, product_or_template):
"""Return the extra price for a product template attribute value.
: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.
"""
return ptav.currency_id._convert(
ptav.price_extra,
currency,
request.env.company,
date.date(),
)
def _should_show_product(self, product_template, parent_combination):
"""Decide whether a product should be shown in the configurator.
: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.
"""
return True

View file

@ -1,39 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from odoo import http
from odoo.http import request
class VariantController(http.Controller):
@http.route(['/sale/get_combination_info'], type='json', auth="user", methods=['POST'])
def get_combination_info(self, product_template_id, product_id, combination, add_qty, pricelist_id, **kw):
combination = request.env['product.template.attribute.value'].browse(combination)
pricelist = self._get_pricelist(pricelist_id)
cids = request.httprequest.cookies.get('cids', str(request.env.user.company_id.id))
allowed_company_ids = [int(cid) for cid in cids.split(',')]
ProductTemplate = request.env['product.template'].with_context(allowed_company_ids=allowed_company_ids)
if 'context' in kw:
ProductTemplate = ProductTemplate.with_context(**kw.get('context'))
product_template = ProductTemplate.browse(int(product_template_id))
res = product_template._get_combination_info(combination, int(product_id or 0), int(add_qty or 1), pricelist)
if 'parent_combination' in kw:
parent_combination = request.env['product.template.attribute.value'].browse(kw.get('parent_combination'))
if not combination.exists() and product_id:
product = request.env['product.product'].browse(int(product_id))
if product.exists():
combination = product.product_template_attribute_value_ids
res.update({
'is_combination_possible': product_template._is_combination_possible(combination=combination, parent_combination=parent_combination),
'parent_exclusions': product_template._get_parent_attribute_exclusions(parent_combination=parent_combination)
})
return res
@http.route(['/sale/create_product_variant'], type='json', auth="user", methods=['POST'])
def create_product_variant(self, product_template_id, product_template_attribute_value_ids, **kwargs):
return request.env['product.template'].browse(int(product_template_id)).create_product_variant(json.loads(product_template_attribute_value_ids))
def _get_pricelist(self, pricelist_id, pricelist_fallback=False):
return request.env['product.pricelist'].browse(int(pricelist_id or 0))