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