Advertisement
🌐 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

Portal Forms: Letting Customers Submit Data

Beyond viewing their records, portal users often need to submit data — update contact details, request a quote, add a support message. Portal forms handle this through controllers that validate and write to models.

@route("/my/account/update", type="http", auth="user", website=True, methods=["POST"])
def update_details(self, **post):
    partner = request.env.user.partner_id     # the logged-in user's own partner
    partner.sudo().write({                      # sudo carefully — only their record
        "phone": post.get("phone"),
        "city": post.get("city"),
    })
    return request.redirect("/my/account")
ConcernHandling
CSRFinclude the token; Odoo blocks forged POSTs
Validationre-check every field server-side
Authorizationwrite only to the user's OWN records

The flow: a QWeb template renders an HTML form that POSTs to a controller route (auth="user" so only logged-in users reach it); the controller reads **post, validates, and writes to the model. Security is critical because input is user-controlled: always keep the CSRF token in the form (Odoo rejects POSTs without it, preventing cross-site request forgery); re-validate every field on the server (client checks are cosmetic); and — the big one — only ever write to records the user owns. Deriving the target from request.env.user.partner_id is safe; trusting an id from the form is not. The sudo() nuance: portal users have minimal write rights, so you often need sudo() to save — but scope it tightly to their record. A careless sudo().write() driven by a form-supplied id lets a user modify anyone's data. Good practice: show clear success/error feedback and redirect after a successful POST (Post/Redirect/Get) so a refresh doesn't resubmit.

🏋️ Practical Exercise

  1. Add a portal form for customer input.
  2. Submit it to a controller.
  3. Validate the input.
  4. Create or update a record.
  5. Show a confirmation message.

🔥 Challenge Exercise

Build a portal form that lets a customer update their contact details or submit a request, posting to a controller that validates and saves the data to their own record. Explain CSRF protection on portal forms and why you must re-check ownership server-side.

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>
Advertisement

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

Interview Questions

  • How do you build a form on the portal?
  • How is a portal form submission handled?
  • What is CSRF protection and does Odoo add it?
  • Why re-check record ownership on the server?
  • How do you show validation errors on a portal form?

FAQ

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.