Files
odoo_source/addons/website_quote/controllers/main.py
T
Ravi Gadhia 762adedece [IMP] various: improve section and add note on sale order and invoice
The goal is to be able to more easily create section and notes on sale order, invoice, and quote.

================================
Problems of the existing feature
================================

The problematic part in the existing feature was that the same section had to be defined again and again for every line, instead of being defined only once where it needed to be. There was also no easy way to move them. Those concerns are solved here.

Moreover, due to the complexity of the feature, it was hidden by default, requiring a setting and multiple overrides. The new feature is simple enough that it will always be present.

==================
New implementation
==================

Sections are lines solely used for display purpose, there is no business logic attached to them. They will show as a nice header at the top of the section, and a subtotal at the bottom of the section.

We also take the opportunity to add notes, which are just like sections except they are displayed differently and don't include a subtotal.

=========
Reasoning
=========

We decided to implement it this way, as opposed to creating a proper section model, because it creates less changes in the code.

Creating a intermediary section model between the sale order/invoice/quote and their lines would have required to rewrite the entirety of the code associated to them. It would also have required to create a new JS widget to handle on the same view a double-level One2many (or a One2many + group by).

The current solution is much easier because it just requires adding 2 buttons, 1 simple field on the corresponding models which even has a NULL default value, and making some business checks (don't apply business to section/note).

We tried our best to impact as less as possible the existing models. Notably we didn't want to add any extra field if this was avoidable. This also makes the migration easier.

No matter the implementation choice the views/reports had to be updated to display the sections and notes correctly, which has been done.

===========
Limitations
===========

The current solution has several limitations:

* since the sections don't have business logic, it's not possible to make any reporting based on them

* any business related to sections has to be computed on the fly by browsing in order all of the lines, notably the subtotal has to be computed on the fly at every display

- when sections are moved (drag&drop) on the back-end, the following lines that are part of the section are not moving with the section

===========
Technically
===========

The commit will:

* update the models to add the display_type field (along with related sql constraints to guarantee integrity)

* add new buttons at the bottom of the lists to allow creating section and note, along with the existing add a line/product button

* add some business checks to not apply business to section/note (product_id and account_id became non-required fields)

!!! The most likely cause of potential bugs introduced in this commit would be to have forgotten to add those checks on some places where they would be needed. This is tedious to check but fortunately easy to fix.

* update the views to display sections and notes correctly, also made various improvements/fixes to those views (notably to apply B2B/B2C setting more consistently)

!!! This is the second likely cause of potential bugs: if the updated views are inherited in a way that is not compatible with the changes. Or if we forgot to apply the changes to some other views. Again this is tedious to check but fortunately easy to fix.

Task #53996
PR #25209
2018-07-17 11:50:35 +02:00

226 lines
11 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import werkzeug
from odoo import exceptions, fields, http, _
from odoo.http import request
from odoo.addons.portal.controllers.portal import get_records_pager
from odoo.addons.sale.controllers.portal import CustomerPortal
from odoo.addons.portal.controllers.mail import _message_post_helper
from odoo.osv import expression
class CustomerPortal(CustomerPortal):
@http.route()
def portal_order_page(self, order=None, access_token=None, **kw):
try:
order_sudo = self._order_check_access(order, access_token=access_token)
except exceptions.AccessError:
pass
else:
if order_sudo.template_id and order_sudo.template_id.active:
return request.redirect('/quote/%s/%s' % (order, access_token or ''))
return super(CustomerPortal, self).portal_order_page(order=order, access_token=access_token, **kw)
def _portal_quote_user_can_accept(self, order):
result = super(CustomerPortal, self)._portal_quote_user_can_accept(order)
# either use quote template settings or fallback on default behavior
return order.require_signature if order.template_id else result
class sale_quote(http.Controller):
@http.route("/quote/<int:order_id>", type='http', auth="user", website=True)
def view_user(self, *args, **kwargs):
return self.view(*args, **kwargs)
@http.route("/quote/<int:order_id>/<token>", type='http', auth="public", website=True)
def view(self, order_id, pdf=None, token=None, message=False, **post):
# use sudo to allow accessing/viewing orders for public user
# only if he knows the private token
now = fields.Date.today()
if token:
Order = request.env['sale.order'].sudo().search([('id', '=', order_id), ('access_token', '=', token)])
else:
Order = request.env['sale.order'].search([('id', '=', order_id)])
# Log only once a day
if Order and request.session.get('view_quote_%s' % Order.id) != now and request.env.user.share:
request.session['view_quote_%s' % Order.id] = now
body = _('Quotation viewed by customer')
_message_post_helper(res_model='sale.order', res_id=Order.id, message=body, token=Order.access_token, message_type='notification', subtype="mail.mt_note", partner_ids=Order.user_id.sudo().partner_id.ids)
if not Order:
return request.render('website.404')
# Token or not, sudo the order, since portal user has not access on
# taxes, required to compute the total_amout of SO.
order_sudo = Order.sudo()
days = 0
if order_sudo.validity_date:
days = (fields.Date.from_string(order_sudo.validity_date) - fields.Date.from_string(fields.Date.today())).days + 1
if pdf:
pdf = request.env.ref('website_quote.report_web_quote').sudo().with_context(set_viewport_size=True).render_qweb_pdf([order_sudo.id])[0]
pdfhttpheaders = [('Content-Type', 'application/pdf'), ('Content-Length', len(pdf))]
return request.make_response(pdf, headers=pdfhttpheaders)
transaction = order_sudo.get_portal_last_transaction()
values = {
'quotation': order_sudo,
'message': message and int(message) or False,
'option': any(not x.line_id for x in order_sudo.options),
'order_valid': (not order_sudo.validity_date) or (now <= order_sudo.validity_date),
'days_valid': days,
'action': request.env.ref('sale.action_quotations').id,
'no_breadcrumbs': request.env.user.partner_id.commercial_partner_id not in order_sudo.message_partner_ids,
'tx_id': transaction.id if transaction else False,
'tx_state': transaction.state if transaction else False,
'payment_tx': transaction,
'tx_post_msg': transaction.acquirer_id.post_msg if transaction else False,
'need_payment': order_sudo.invoice_status == 'to invoice' and transaction.state in ['draft', 'cancel'],
'token': token,
'return_url': '/shop/payment/validate',
'bootstrap_formatting': True,
'partner_id': order_sudo.partner_id.id,
}
if order_sudo.has_to_be_paid() or values['need_payment']:
domain = expression.AND([
['&', ('website_published', '=', True), ('company_id', '=', order_sudo.company_id.id)],
['|', ('specific_countries', '=', False), ('country_ids', 'in', [order_sudo.partner_id.country_id.id])]
])
acquirers = request.env['payment.acquirer'].sudo().search(domain)
values['form_acquirers'] = [acq for acq in acquirers if acq.payment_flow == 'form' and acq.view_template_id]
values['s2s_acquirers'] = [acq for acq in acquirers if acq.payment_flow == 's2s' and acq.registration_view_template_id]
values['pms'] = request.env['payment.token'].search(
[('partner_id', '=', order_sudo.partner_id.id),
('acquirer_id', 'in', [acq.id for acq in values['s2s_acquirers']])])
history = request.session.get('my_quotes_history', [])
values.update(get_records_pager(history, order_sudo))
return request.render('website_quote.so_quotation', values)
@http.route(['/quote/<int:order_id>/<token>/decline'], type='http', auth="public", methods=['POST'], website=True)
def decline(self, order_id, token, **post):
Order = request.env['sale.order'].sudo().browse(order_id)
if token != Order.access_token:
return request.render('website.404')
if Order.state != 'sent':
return werkzeug.utils.redirect("/quote/%s/%s?message=4" % (order_id, token))
Order.action_cancel()
message = post.get('decline_message')
if message:
_message_post_helper(message=message, res_id=order_id, res_model='sale.order', **{'token': token} if token else {})
return werkzeug.utils.redirect("/quote/%s/%s?message=2" % (order_id, token))
@http.route(['/quote/update_line'], type='json', auth="public", website=True)
def update(self, line_id, remove=False, unlink=False, order_id=None, token=None, **post):
Order = request.env['sale.order'].sudo().browse(int(order_id))
if token != Order.access_token:
return request.render('website.404')
if Order.state not in ('draft', 'sent'):
return False
OrderLine = request.env['sale.order.line'].sudo().browse(int(line_id))
if unlink:
OrderLine.unlink()
return False
number = -1 if remove else 1
quantity = OrderLine.product_uom_qty + number
OrderLine.write({'product_uom_qty': quantity})
return [str(quantity), str(Order.amount_total)]
@http.route(["/quote/template/<model('sale.quote.template'):quote>"], type='http', auth="user", website=True)
def template_view(self, quote, **post):
values = {'template': quote}
return request.render('website_quote.so_template', values)
@http.route(["/quote/add_line/<int:option_id>/<int:order_id>/<token>"], type='http', auth="public", website=True)
def add(self, option_id, order_id, token, **post):
Order = request.env['sale.order'].sudo().browse(order_id)
if token != Order.access_token:
return request.render('website.404')
if Order.state not in ['draft', 'sent']:
return request.render('website.http_error', {'status_code': 'Forbidden', 'status_message': _('You cannot add options to a confirmed order.')})
Option = request.env['sale.order.option'].sudo().browse(option_id)
vals = {
'price_unit': Option.price_unit,
'website_description': Option.website_description,
'name': Option.name,
'order_id': Order.id,
'product_id': Option.product_id.id,
'product_uom_qty': Option.quantity,
'product_uom': Option.uom_id.id,
'discount': Option.discount,
}
OrderLine = request.env['sale.order.line'].sudo().create(vals)
OrderLine._compute_tax_id()
Option.write({'line_id': OrderLine.id})
return werkzeug.utils.redirect("/quote/%s/%s#pricing" % (Order.id, token))
# note dbo: website_sale code
@http.route(['/quote/<int:order_id>/transaction/'], type='json', auth="public", website=True)
def payment_transaction_token(self, acquirer_id, order_id, save_token=False,access_token=None, **kwargs):
""" Json method that creates a payment.transaction, used to create a
transaction when the user clicks on 'pay now' button. After having
created the transaction, the event continues and the user is redirected
to the acquirer website.
:param int acquirer_id: id of a payment.acquirer record. If not set the
user is redirected to the checkout page
"""
# Ensure a payment acquirer is selected
if not acquirer_id:
return False
try:
acquirer_id = int(acquirer_id)
except:
return False
order = request.env['sale.order'].sudo().browse(order_id)
if not order or not order.order_line:
return False
# Create transaction
vals = {
'acquirer_id': acquirer_id,
'type': order._get_payment_type(),
}
transaction = order._create_payment_transaction(vals)
return transaction.render_sale_button(
order,
'/quote/%s/%s' % (order_id, access_token) if access_token else '/quote/%s' % order_id,
submit_txt=_('Pay & Confirm'),
render_values={
'type': order._get_payment_type(),
'alias_usage': _('If we store your payment information on our server, subscription payments will be made automatically.'),
}
)
@http.route('/quote/<int:order_id>/transaction/token', type='http', auth='public', website=True)
def payment_token(self, order_id, pm_id=None, **kwargs):
order = request.env['sale.order'].sudo().browse(order_id)
if not order or not order.order_line or pm_id is None:
return request.redirect("/quote/%s" % order_id)
# try to convert pm_id into an integer, if it doesn't work redirect the user to the quote
try:
pm_id = int(pm_id)
except ValueError:
return request.redirect('/quote/%s' % order_id)
# Create transaction
vals = {
'payment_token_id': pm_id,
'type': 'server2server',
}
order._create_payment_transaction(vals)
return request.redirect('/quote/%s/%s' % (order_id, order.access_token))