Not every client is willing to set up a portal account. A customer receiving just one quote may set up an account, open it, obtain a quote, and never log in to anything again. Forcing the account leads to the quote going unread and unusable. Just providing a simple URL means that anyone who knows the record ID has access to your pricing.
In between the two previous approaches sits the token link. Instead of an account, the actual page is protected by a long random secret stored with the order and transported with the link, and it is valid only for 7 days. The link leads to a small landing page where the client may choose to sign in or simply read the quote as a guest. In this post, we are going to create the token and expiry for sale.order, a public controller that validates both, three simple pages, and a link sent with the quotation email.
Adding the Token to the Sale Order
Python Code:
import secrets
from datetime import timedelta
from odoo import fields, models
class SaleOrder(models.Model):
_inherit = 'sale.order'
share_token = fields.Char(string="Share Token", copy=False, readonly=True, index=True)
token_expiry = fields.Datetime(string="Link Expiry", copy=False, readonly=True)
share_link = fields.Char(string="Customer Link", compute='_compute_share_link')
def _compute_share_link(self):
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
for order in self:
order.share_link = (
f'{base_url}/my/order/landing/{order.id}/{order.share_token}'
if order.share_token else False
)
def action_generate_share_link(self):
"""Issue a token valid for 7 days."""
for order in self:
order.write({
'share_token': secrets.token_urlsafe(32),
'token_expiry': fields.Datetime.now() + timedelta(days=7),
})
return True
def action_quotation_send(self):
"""Make sure a valid link exists before the mail is composed."""
for order in self:
if not order.share_token or order.token_expiry < fields.Datetime.now():
order.action_generate_share_link()
return super().action_quotation_send()
The two fields that are stored represent the secret and the expiration date, while the computed field creates the URL for the output. The statement secrets.token_urlsafe(32) is the most relevant in that it brings to life an approximately 256-bit value that is from a cryptographically safe source, and it is composed of characters that do not get modified in a URL context.
A UUID, or a hash of an ID, or anything else identical to that can be guessed; thus, all the features depend on the token being the only unpredictable detail. Both fields use copy=False so that if a citation is duplicated, there will not be a duplicated URL, and since the token is indexed, the controller uses it during each anonymous page load. The fact that the override action_quotation_send is used implies that, at the time when the emailing template containing the token is generated, the token already exists; thus, the check for the token being expired will only yield dead tokens, so that a person who already has a working token will not lose it.
The Public Controller
Python code:
from odoo import fields, http
from odoo.http import request
class SaleOrderTokenController(http.Controller):
def _get_order(self, order_id, token):
"""Return the order only if id, token and expiry all match."""
return request.env['sale.order'].sudo().search([
('id', '=', order_id),
('share_token', '=', token),
('token_expiry', '>=', fields.Datetime.now()),
], limit=1)
@http.route('/my/order/landing/<int:order_id>/<string:token>',
type='http', auth='public', website=True, sitemap=False)
def order_landing(self, order_id, token, **kw):
"""Landing page: sign in or continue as a guest."""
order = self._get_order(order_id, token)
if not order:
return request.render('sale_order_token_landing.order_token_expired')
return request.render('sale_order_token_landing.order_landing_page', {
'order': order,
'guest_url': f'/my/order/guest/{order.id}/{token}',
})
@http.route('/my/order/guest/<int:order_id>/<string:token>',
type='http', auth='public', website=True, sitemap=False)
def order_guest_view(self, order_id, token, **kw):
"""Read only quotation page, no account needed."""
order = self._get_order(order_id, token)
if not order:
return request.render('sale_order_token_landing.order_token_expired')
return request.render('sale_order_token_landing.order_guest_page', {'order': order})
When using auth='public', the execution takes place using the public user that doesn’t have access to sale.order records, which necessitates the use of sudo() for anything being rendered. The verification mechanism that is acting instead of an access rights check is the search domain, as all three conditions - id, token, and expiry - are included in a single domain that returns the result only if all three conditions hold. This point has its significance, as using this method instead of trying to fetch the record and then checking the token means that a modification made afterwards won’t be able to remove any condition without affecting the search. A different condition will lead to a result that gives the information on the specific URL to work with a specific part of the URL.
The Landing and Guest Pages
xml code:
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Landing page: sign in or continue as a guest -->
<template id="order_landing_page" name="Quotation Landing">
<t t-call="website.layout">
<div class="container my-5">
<h2>Quotation <span t-field="order.name"/></h2>
<p>Hello <span t-field="order.partner_id.name"/>, your quotation is ready.</p>
<p class="fs-5">
Total:
<span t-field="order.amount_total"
t-options='{"widget": "monetary", "display_currency": order.currency_id}'/>
</p>
<div class="mt-4">
<a href="/web/login" class="btn btn-primary">Sign In</a>
<a t-att-href="guest_url" class="btn btn-secondary ms-2">Continue as Guest</a>
</div>
<small class="text-muted d-block mt-4">
This link stops working on <span t-field="order.token_expiry"/>.
</small>
</div>
</t>
</template>
<!-- Guest view: read only quotation -->
<template id="order_guest_page" name="Quotation Guest View">
<t t-call="website.layout">
<div class="container my-5">
<h3>Quotation <span t-field="order.name"/></h3>
<table class="table table-sm mt-4">
<thead>
<tr>
<th>Product</th>
<th class="text-end">Quantity</th>
<th class="text-end">Unit Price</th>
<th class="text-end">Subtotal</th>
</tr>
</thead>
<tbody>
<tr t-foreach="order.order_line" t-as="line">
<td><span t-field="line.product_id.display_name"/></td>
<td class="text-end"><span t-field="line.product_uom_qty"/></td>
<td class="text-end">
<span t-field="line.price_unit"
t-options='{"widget": "monetary", "display_currency": order.currency_id}'/>
</td>
<td class="text-end">
<span t-field="line.price_subtotal"
t-options='{"widget": "monetary", "display_currency": order.currency_id}'/>
</td>
</tr>
</tbody>
</table>
<p class="text-end fs-5">
Total:
<span t-field="order.amount_total"
t-options='{"widget": "monetary", "display_currency": order.currency_id}'/>
</p>
</div>
</t>
</template>
<!-- Wrong or expired link -->
<template id="order_token_expired" name="Quotation Link Not Valid">
<t t-call="website.layout">
<div class="container my-5 text-center">
<h3>This link is no longer valid</h3>
<p class="text-muted">
The link has expired or is incorrect. Please contact us for a new one.
</p>
</div>
</t>
</template>
</odoo>
All three invoke the website.layout, so that the pages retain the header, footer, and theme of the site as opposed to being plain HTML. The landing page displays the name of the order and the total amount, giving the visitor enough information to identify what the link was about while leaving all details just a click away. Each currency field goes through the display_currency method, since the public page does not have any user currency to fall back on and would print in company currency regardless of the currency of the order otherwise. Displaying the expiration date would save one email to the support team because a customer who comes back after two weeks would already know the reason why the link stopped working.
Sending the Link with the Quotation Mail
xml code:
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="mail_template_quotation_share_link" model="mail.template">
<field name="name">Quotation: Customer Link</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">Your quotation {{ object.name }}</field>
<field name="email_from">{{ (object.user_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-size:13px;">
<p>Hello <t t-out="object.partner_id.name or ''">Customer</t>,</p>
<p>Your quotation <t t-out="object.name or ''">S00001</t> is ready for review.</p>
<p>
<a t-att-href="object.share_link"
style="background-color:#875A7B;padding:8px 16px;color:#fff;text-decoration:none;border-radius:5px;">
View Quotation
</a>
</p>g
<p style="color:#888;">This link is valid for 7 days.</p>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
</record>
</data>
</odoo>
A separate template is employed instead of modifying the sale.email_template_edi_sale record, as the record is a mail.template, not a QWeb view; thus, it cannot be inherited with a template. The file uses the noupdate="1" parameter so that the upgrade does not replace the text which has already been changed by a client. Now, to use the template, you need to select it in the Send composer or set it as the default quotation template.
Selecting the Customer Link mail template in the Send dialog:

The rendered mail with the View Quotation button and the generated link and expiry on the quotation form:

Landing page: the customer chooses to sign in:

The Sign In button leads to the standard Odoo login page:

Landing page: the customer chooses to continue as a guest:

Guest view showing the quotation lines with no account required:

The token sharing flow for Odoo 19 was created in 3 simple steps: it required a random secret and order expiration, a controller that checks these two variables in one search domain, and some website templates for landing and guest pages, as well as an expired page. The approach is not limited to quotations; an invoice or a delivery note can be presented in a similar manner, where the only thing that differs is the model used and the content of the mentioned page. The most important points are that the random source must be of high quality and all conditions must be in a domain and not be checked in the code later.
To read more about How to Use Public Controllers in Odoo 19, refer to our blog How to Use Public Controllers in Odoo 19.