Ad – 728×90
🌐 Portal Development

Odoo Portal Forms – Accepting User Input from the Portal

Portal forms let customers submit data from the portal — extend loan requests, file returns, update contact info, or upload documents. The pattern involves a GET handler that renders the form and a POST handler that validates and processes the submission.

⏱️ 20 min 🎯 Intermediate 📅 Updated 2026
What you'll learn:
  • How CSRF protection works in Odoo portal forms
  • The GET/POST controller pattern
  • Validating form data and displaying errors
  • File uploads via request.httprequest.files

Form Submission in the Portal

Portal forms use standard HTML <form> elements with method="post". The controller has two responsibilities:

  • GET — render the empty form (or pre-fill it with existing data)
  • POST — process the submitted data, validate it, create/update records, and redirect or re-render with errors

CSRF Protection

Odoo requires a CSRF token on every POST form to prevent cross-site request forgery. Add a hidden input using request.csrf_token():

XML (QWeb)
<form action="/my/loans/request" method="post">
  <input type="hidden" name="csrf_token"
         t-att-value="request.csrf_token()"/>

  <div class="form-group">
    <label for="book_id">Book ID</label>
    <input type="number" name="book_id" class="form-control" required/>
  </div>

  <div class="form-group">
    <label for="notes">Notes</label>
    <textarea name="notes" class="form-control" rows="3"></textarea>
  </div>

  <button type="submit" class="btn btn-primary">Submit Request</button>
</form>

Without the CSRF token, Odoo returns a 403 error on POST.

Controller for Form POST

Handle both GET and POST in one controller method using methods=['GET', 'POST'], or use two separate methods with the same route:

Python
from odoo import http
from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal


class LibraryPortal(CustomerPortal):

    @http.route('/my/loans/request', type='http',
                auth='user', website=True, methods=['GET', 'POST'])
    def loan_request(self, **kw):
        if request.httprequest.method == 'POST':
            return self._process_loan_request(kw)

        # GET: show empty form
        books = request.env['library.book'].sudo().search([('available', '=', True)])
        return request.render('my_module.portal_loan_request_form', {
            'books': books,
            'errors': {},
        })

    def _process_loan_request(self, values):
        errors = {}

        book_id = int(values.get('book_id', 0))
        if not book_id:
            errors['book_id'] = 'Please select a book.'

        notes = (values.get('notes') or '').strip()

        if errors:
            books = request.env['library.book'].sudo().search([('available', '=', True)])
            return request.render('my_module.portal_loan_request_form', {
                'books': books,
                'errors': errors,
                'values': values,
            })

        # Create record
        book = request.env['library.book'].sudo().browse(book_id)
        request.env['library.loan'].sudo().create({
            'member_id': request.env.user.partner_id.id,
            'book_id': book.id,
            'notes': notes,
            'state': 'requested',
        })

        return request.redirect('/my/loans?request_sent=1')

Displaying Errors

Pass an errors dict from controller to template and render inline error messages:

XML (QWeb)
<div t-attf-class="form-group #{errors.get('book_id') and 'has-error' or ''}">
  <label>Book</label>
  <select name="book_id" class="form-control">
    <option value="">-- Select a book --</option>
    <t t-foreach="books" t-as="book">
      <option t-att-value="book.id"
              t-att-selected="values.get('book_id') == str(book.id)">
        <t t-out="book.name"/>
      </option>
    </t>
  </select>
  <t t-if="errors.get('book_id')">
    <span class="help-block text-danger">
      <t t-out="errors['book_id']"/>
    </span>
  </t>
</div>
Ad – 728×90

File Uploads in Portal

To allow file uploads, set enctype="multipart/form-data" on the form and access files via request.httprequest.files:

Python
attachment_file = request.httprequest.files.get('attachment')
if attachment_file and attachment_file.filename:
    attachment_data = attachment_file.read()
    request.env['ir.attachment'].sudo().create({
        'name': attachment_file.filename,
        'datas': base64.b64encode(attachment_data),
        'res_model': 'library.loan',
        'res_id': loan.id,
    })

Always validate file type and size before storing. Check attachment_file.content_type and len(attachment_data).

Key takeaways:
  • Always include <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/> in forms
  • Use request.httprequest.method to distinguish GET from POST
  • On validation failure, re-render the form with errors dict and previous values
  • On success, always redirect — prevents duplicate submissions on browser refresh

Frequently Asked Questions

Why always redirect after a successful POST?

This is the POST/Redirect/GET pattern. If the user hits browser refresh after a successful POST without a redirect, the form would be re-submitted. A redirect turns the final response into a GET, which is safe to refresh.

How do I pre-fill form fields after a validation failure?

Pass the original values dict back to the template and use t-att-value="values.get('field_name', '')" on each input. For selects, compare with t-att-selected.

Do I need sudo() when creating records from portal forms?

Yes, in most cases. Portal users have restricted access by design. Use sudo() to create records on their behalf, but always validate ownership and permissions first to prevent privilege escalation.