Advertisement
🌐 Portal Development

Odoo Portal Controllers – Routing and HTTP Responses

Portal controllers are Python classes that handle HTTP requests for your portal pages. You extend Odoo's built-in CustomerPortal controller, add your routes with @http.route, query records from request.env, and return rendered QWeb templates.

⏱️ 22 min 🎯 Intermediate 📅 Updated 2026

Portal Controllers: The /my Pages Customers See

The customer portal (the /my/... pages where clients view their orders, invoices, tickets) is built by extending Odoo's CustomerPortal controller. You add routes for your own records and reuse the portal's layout helpers.

from odoo.addons.portal.controllers.portal import CustomerPortal
from odoo.http import request, route

class MyPortal(CustomerPortal):

    @route("/my/projects", auth="user", website=True)
    def portal_projects(self, **kw):
        projects = request.env["project.project"].search([])  # record rules apply
        values = self._prepare_portal_layout_values()
        values["projects"] = projects
        return request.render("my_module.portal_projects", values)
PieceRole
auth="user"logged-in customers only
_prepare_portal_layout_values()breadcrumbs, counters, sidebar
record rulesauto-limit records to the current customer

Why inherit CustomerPortal: you get the portal's consistent layout, the "Documents" home page counters, and pagination helpers for free — instead of rebuilding them. Override _prepare_home_portal_values() to add your model's count to the portal dashboard.

Security is the crux: portal users are external, so a route must never trust an id from the URL blindly. Rely on record rules to scope search() to the current partner, and when fetching a single record by id, verify ownership (call check_access_rule or check partner_id) before rendering — otherwise one customer can read another's data by changing the URL number.

🏋️ Practical Exercise

  1. Create a portal controller extending CustomerPortal.
  2. Add a /my/items route.
  3. List the current user’s records.
  4. Add a counter to the portal home.
  5. Restrict the query to the logged-in user’s data.

🔥 Challenge Exercise

Write a portal controller (extending portal.CustomerPortal) exposing /my/orders that lists only the current user’s orders, and add the counter to the portal home. Explain how _prepare_home_portal_values and filtering by partner_id keep data private.

What you'll learn:
  • How Odoo's HTTP controller layer works
  • The @route decorator and its parameters
  • Extending CustomerPortal to 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:

Python
@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:

Python
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:

Python
    @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:

Python
    @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.

Advertisement

Handling 404s and Access Control

Useful patterns for error handling:

Python
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()
Key takeaways:
  • Extend CustomerPortal and override _prepare_home_portal_values for the counter
  • Use portal_pager for pagination on listing pages
  • Use _document_check_access on detail pages — it validates ownership OR access token
  • Detail pages use auth='public' so shared token links work for anonymous visitors

Interview Questions

  • How do you add a page to the customer portal?
  • What class do portal controllers extend?
  • How do you show only the current user’s records?
  • What is _prepare_home_portal_values?
  • How do you add a counter to the portal home?

FAQ

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.