- The built-in
website_formwidget and when to use it - Writing a custom POST controller for website form submissions
- CSRF and SPAM protection on public forms
- Handling file uploads and confirmation redirects
The website_form Widget
For standard forms that create records (leads, job applications, contact messages), the website_form JavaScript widget handles submission, validation, and SPAM protection automatically. Enable it by adding data-model-name to your form tag:
<form id="contact_form"
class="s_website_form"
action="/website_form/"
method="post"
data-model-name="crm.lead"
data-success-page="/contactus-thank-you">
<input type="hidden" name="csrf_token"
t-att-value="request.csrf_token()"/>
<div class="form-group">
<label for="contact_name">Name</label>
<input type="text" class="form-control"
name="contact_name" required="required"/>
</div>
<div class="form-group">
<label for="email_from">Email</label>
<input type="email" class="form-control"
name="email_from" required="required"/>
</div>
<div class="form-group">
<label for="description">Message</label>
<textarea class="form-control" name="description" rows="5"/>
</div>
<button type="submit" class="btn btn-primary">Send</button>
</form>
The website_form widget posts to /website_form/{model_name} and creates a record using the form field names as model field names. The model must allow website form creation — configure this in Website → Configuration → Form Allowed Models.
Custom POST Controller
For custom logic (multi-step forms, complex validation, multiple model writes), write your own controller:
from odoo import http
from odoo.http import request
class LibraryWebsite(http.Controller):
@http.route('/books/request', type='http',
auth='public', website=True, methods=['GET', 'POST'])
def book_request_form(self, **kw):
if request.httprequest.method == 'POST':
return self._handle_book_request(kw)
books = request.env['library.book'].sudo().search([
('website_published', '=', True)
])
return request.render('my_module.book_request_form', {
'books': books, 'errors': {}, 'values': {},
})
def _handle_book_request(self, values):
errors = {}
name = (values.get('visitor_name') or '').strip()
email = (values.get('visitor_email') or '').strip()
book_id = int(values.get('book_id') or 0)
if not name:
errors['visitor_name'] = 'Name is required.'
if not email or '@' not in email:
errors['visitor_email'] = 'Valid email required.'
if not book_id:
errors['book_id'] = 'Please select a book.'
if errors:
books = request.env['library.book'].sudo().search([
('website_published', '=', True)
])
return request.render('my_module.book_request_form', {
'books': books, 'errors': errors, 'values': values,
})
request.env['library.book.request'].sudo().create({
'visitor_name': name,
'visitor_email': email,
'book_id': book_id,
})
return request.redirect('/books/request/thank-you')
CSRF Protection on Public Forms
All POST forms must include the CSRF token. For public (unauthenticated) pages, the token is still required:
<form method="post" action="/books/request">
<input type="hidden" name="csrf_token"
t-att-value="request.csrf_token()"/>
...
</form>
Without the CSRF token, Odoo returns HTTP 403. The token is tied to the visitor's session, which Odoo creates automatically even for anonymous visitors.
SPAM Protection
Public forms are targets for bot spam. Odoo's website_form widget adds a honeypot field automatically. For custom forms, implement your own protection:
- Honeypot field — an invisible input; if filled in, it's a bot:
<!-- Hidden from real users via CSS, filled by bots -->
<input type="text" name="website_url"
style="display:none" tabindex="-1" autocomplete="off"/>
# In controller: reject if honeypot filled
if values.get('website_url'):
return request.redirect('/books/request') # silently ignore bots
File Uploads
For file uploads, add enctype="multipart/form-data" to the form and access files in the controller:
import base64
attachment = request.httprequest.files.get('document')
if attachment and attachment.filename:
data = attachment.read()
# Validate size (max 5 MB)
if len(data) > 5 * 1024 * 1024:
errors['document'] = 'File must be under 5 MB.'
else:
request.env['ir.attachment'].sudo().create({
'name': attachment.filename,
'datas': base64.b64encode(data),
'res_model': 'library.book.request',
'res_id': book_request.id,
})
- Use the
website_formwidget for simple record-creation forms — it handles SPAM and validation automatically - For custom logic, write a GET/POST controller with manual validation and a POST/Redirect/GET pattern
- Always include
csrf_tokenin every POST form - Validate file type and size before creating
ir.attachmentrecords
Frequently Asked Questions
When should I use website_form vs a custom POST controller?
Use website_form when you're creating a single record (lead, contact message, job application) and standard field mapping is sufficient. Use a custom controller when you need multi-model writes, complex validation, file uploads alongside record creation, or conditional logic based on submitted values.
How do I show a thank-you page after form submission?
Redirect to a dedicated thank-you URL after successful processing: return request.redirect('/my-form/thank-you'). Create a simple controller and template for that URL. This also prevents duplicate submissions on browser refresh (POST/Redirect/GET pattern).
Can I use reCAPTCHA with Odoo website forms?
Yes — the google_recaptcha module integrates reCAPTCHA v3 with the website_form widget. Configure the site key in Website → Configuration → Settings. For custom forms, call request.env['ir.recaptcha']._verify_recaptcha_token(token) in your controller.