- How Odoo's HTTP controller layer works
- The
@routedecorator and its parameters - Extending
CustomerPortalto add your model to the portal home - Building listing and detail page controllers with pager and access checks
Odoo HTTP Controllers
Odoo's web layer is built on Werkzeug. Controllers are Python classes that inherit from odoo.http.Controller. For portal pages specifically, you inherit from CustomerPortal which itself inherits Controller and adds helpers for access token validation, pager, and the portal home page.
File location in your module: controllers/portal.py (import it in controllers/__init__.py).
The @route Decorator
Every controller method that handles a URL is decorated with @http.route:
@http.route(
['/my/loans', '/my/loans/page/<int:page>'],
type='http', # HTTP request (not JSON-RPC)
auth='user', # requires login (portal or internal user)
website=True, # enables website context (multi-website, theme, etc.)
methods=['GET'],
)
def portal_my_loans(self, page=1, **kw):
...
Key auth values:
'user'— logged-in users only (portal or internal)'public'— anyone, including anonymous visitors'none'— no session required (raw access)
Portal Base Controller
Import and extend CustomerPortal. Override _prepare_home_portal_values to add a counter for your model to the portal home page:
from odoo import http
from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager
class LibraryPortal(CustomerPortal):
def _prepare_home_portal_values(self, counters):
values = super()._prepare_home_portal_values(counters)
if 'loan_count' in counters:
values['loan_count'] = request.env['library.loan'].search_count(
self._get_loans_domain()
)
return values
def _get_loans_domain(self):
return [('member_id', '=', request.env.user.partner_id.id)]
The counter appears on the portal home page (/my/home) as a link to your listing page.
Listing Page Controller
A listing page shows all records for the current user with pagination:
@http.route(
['/my/loans', '/my/loans/page/<int:page>'],
type='http', auth='user', website=True,
)
def portal_my_loans(self, page=1, sortby=None, **kw):
Loan = request.env['library.loan']
domain = self._get_loans_domain()
# Sorting options
searchbar_sortings = {
'date': {'label': 'Loan Date', 'order': 'loan_date desc'},
'name': {'label': 'Book', 'order': 'book_id asc'},
}
if not sortby or sortby not in searchbar_sortings:
sortby = 'date'
order = searchbar_sortings[sortby]['order']
loan_count = Loan.search_count(domain)
pager = portal_pager(
url='/my/loans',
url_args={'sortby': sortby},
total=loan_count,
page=page,
step=10,
)
loans = Loan.search(domain, order=order,
limit=10, offset=pager['offset'])
return request.render('my_module.portal_my_loans', {
'loans': loans,
'pager': pager,
'sortby': sortby,
'searchbar_sortings': searchbar_sortings,
'page_name': 'loan',
})
Detail Page Controller
The detail page shows a single record. Use _document_check_access to verify ownership or a valid access token:
@http.route(
['/my/loans/<int:loan_id>'],
type='http', auth='public', website=True,
)
def portal_loan_detail(self, loan_id, access_token=None, **kw):
try:
loan_sudo = self._document_check_access(
'library.loan', loan_id, access_token
)
except (AccessError, MissingError):
return request.redirect('/my')
return request.render('my_module.portal_loan_detail', {
'loan': loan_sudo,
'page_name': 'loan',
})
_document_check_access raises AccessError if the user doesn't own the record and the token is wrong or missing. Using auth='public' here allows shared token links to work for anonymous visitors.
Handling 404s and Access Control
Useful patterns for error handling:
from odoo.exceptions import AccessError, MissingError
import werkzeug
# Redirect to portal home on error
return request.redirect('/my')
# Return 404
raise werkzeug.exceptions.NotFound()
# Check ownership manually without access token
loan = request.env['library.loan'].browse(loan_id)
if loan.member_id != request.env.user.partner_id:
raise werkzeug.exceptions.Forbidden()
- Extend
CustomerPortaland override_prepare_home_portal_valuesfor the counter - Use
portal_pagerfor pagination on listing pages - Use
_document_check_accesson detail pages — it validates ownership OR access token - Detail pages use
auth='public'so shared token links work for anonymous visitors
Frequently Asked Questions
Why use sudo() when querying records in portal controllers?
After _document_check_access returns, the record is already fetched with sudo context. For listing pages, if your record rules are correctly configured you don't need sudo — request.env['library.loan'].search(...) will automatically filter to the current user's records. Only use sudo() when you need to bypass record rules intentionally.
What is the page_name value used for?
page_name is passed to the template and used by the portal layout to highlight the active item in the portal sidebar navigation. Match it to the key you use in _prepare_home_portal_values.
Can I add search/filter to the listing page?
Yes — add URL parameters (e.g., ?search=python), read them from **kw, and incorporate them into the domain. Pass the search value back to the template to pre-fill the search input.