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")
| Concern | Handling |
|---|---|
| CSRF | include the token; Odoo blocks forged POSTs |
| Validation | re-check every field server-side |
| Authorization | write 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
- Add a portal form for customer input.
- Submit it to a controller.
- Validate the input.
- Create or update a record.
- 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.
- 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():
<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:
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:
<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>
File Uploads in Portal
To allow file uploads, set enctype="multipart/form-data" on the form and access files via request.httprequest.files:
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).
- Always include
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>in forms - Use
request.httprequest.methodto 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?
Related Topics
FAQ
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.
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.
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.

