What Is the Odoo Portal?
The Odoo portal is a customer-facing web interface where external users (customers, vendors, employees) can view their documents: invoices, orders, tickets, and more. Building custom portal pages lets you expose your module's data to external users securely.
Portal Architecture Overview
Portal pages consist of three layers: a controller that handles HTTP requests and prepares data, a QWeb template that renders the HTML, and security rules that control which records users can see. The portal inherits from Odoo's website layout, giving you consistent navigation, footer, and styling.
Step 1: Create the Controller
Portal controllers inherit from portal.CustomerPortal to get built-in pagination, breadcrumbs, and access checking:
from odoo import http
from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal
from odoo.addons.portal.controllers.portal import pager as portal_pager
class MaintenancePortal(CustomerPortal):
def _prepare_home_portal_values(self, counters):
values = super()._prepare_home_portal_values(counters)
if 'request_count' in counters:
partner = request.env.user.partner_id
values['request_count'] = request.env[
'maintenance.request'
].sudo().search_count([
('requester_id', '=', partner.id)
])
return values
@http.route(
'/my/maintenance', type='http', auth='user', website=True)
def portal_maintenance_list(self, page=1, sortby=None, **kw):
partner = request.env.user.partner_id
domain = [('requester_id', '=', partner.id)]
searchbar_sortings = {
'date': {'label': 'Date', 'order': 'create_date desc'},
'name': {'label': 'Name', 'order': 'name asc'},
'state': {'label': 'Status', 'order': 'state asc'},
}
sortby = sortby or 'date'
order = searchbar_sortings[sortby]['order']
count = request.env['maintenance.request'].sudo().search_count(domain)
pager = portal_pager(
url='/my/maintenance',
total=count,
page=page,
step=10,
)
requests = request.env['maintenance.request'].sudo().search(
domain, order=order,
limit=10, offset=pager['offset'])
return request.render(
'my_module.portal_maintenance_list', {
'requests': requests,
'page_name': 'maintenance',
'pager': pager,
'default_url': '/my/maintenance',
'sortby': sortby,
'searchbar_sortings': searchbar_sortings,
})
Step 2: Create the QWeb Template
Portal templates extend portal.portal_sidebar or portal.portal_layout:
<template id="portal_maintenance_list"
name="Maintenance Requests"
inherit_id="portal.portal_layout">
<xpath expr="//div[hasclass('o_portal_my_doc_table')]" position="inside">
<t t-if="requests">
<table class="table table-sm">
<thead>
<tr>
<th>Reference</th>
<th>Subject</th>
<th>Status</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<t t-foreach="requests" t-as="req">
<tr>
<td>
<a t-attf-href="/my/maintenance/#{req.id}">
<t t-out="req.name"/>
</a>
</td>
<td><t t-out="req.subject"/></td>
<td>
<span t-attf-class="badge #{req.state == 'done' and 'bg-success' or 'bg-primary'}">
<t t-out="req.state"/>
</span>
</td>
<td><t t-out="req.create_date" t-options='{"widget": "date"}'/></td>
</tr>
</t>
</tbody>
</table>
<div t-if="pager" class="o_portal_pager text-center">
<t t-call="portal.pager"/>
</div>
</t>
<div t-else="" class="alert alert-info">
No maintenance requests found.
</div>
</xpath>
</template>
Step 3: Add Portal Home Counter
Show the count on the main /my page by adding to the portal home template:
<template id="portal_my_home_maintenance"
inherit_id="portal.portal_my_home">
<xpath expr="//div[hasclass('o_portal_docs')]" position="inside">
<t t-call="portal.portal_docs_entry">
<t t-set="icon" t-value="'/my_module/static/img/wrench.svg'"/>
<t t-set="title">Maintenance Requests</t>
<t t-set="url" t-value="'/my/maintenance'"/>
<t t-set="text">View your maintenance requests</t>
<t t-set="count" t-value="request_count"/>
</t>
</xpath>
</template>
Step 4: Detail Page with Sidebar
Individual record pages typically use the sidebar layout for actions:
@http.route(
'/my/maintenance/<int:req_id>',
type='http', auth='user', website=True)
def portal_maintenance_detail(self, req_id, **kw):
record = request.env['maintenance.request'].sudo().browse(req_id)
if not record.exists() or \
record.requester_id != request.env.user.partner_id:
return request.redirect('/my')
return request.render(
'my_module.portal_maintenance_detail', {
'request': record,
'page_name': 'maintenance',
})
Security Considerations
Portal security is critical. Always follow these rules:
- Use
sudo()for search/browse since portal users have minimal ORM access - Always filter by the current user's partner:
request.env.user.partner_id - Verify ownership in detail pages before rendering
- Never expose internal fields (cost, margin, internal notes) to portal
- Use
access_tokenfor sharing links with non-authenticated users
Access Tokens for Shared Links
Allow unauthenticated access via token:
@http.route(
'/my/maintenance/<int:req_id>',
type='http', auth='public', website=True)
def portal_maintenance_detail(self, req_id, access_token=None, **kw):
record = request.env['maintenance.request'].sudo().browse(req_id)
if access_token and access_token == record.access_token:
pass # allow access
elif record.requester_id == request.env.user.partner_id:
pass # owner access
else:
return request.redirect('/my')
Breadcrumb Navigation
Add breadcrumbs by overriding _prepare_portal_layout_values and passing page_name that matches your routes. The portal framework renders breadcrumbs automatically when page_name is set correctly.