Create One2many Field in Odoo 18 Website
By Braincuber Team
Published on January 28, 2026
Backend Odoo forms handle One2many fields automatically—you click "Add a line" and fill in the subform. Website forms are different. There's no ORM, no automatic field rendering, no wizard pop-ups. You need to build the table structure yourself, add JavaScript for row management, and submit everything via JSON-RPC to a controller that creates both parent and child records.
This tutorial builds a complete Event Registration form where visitors can register for an event and add multiple attendees (the One2many relationship). You'll create the models, design the web form with dynamic table rows, write JavaScript for add/remove functionality, and handle the submission with proper data transformation.
What You'll Build: An Event Registration website form where users enter event details and dynamically add multiple attendees. Each attendee row captures name, email, dietary preference, and accessibility needs—all submitted as a One2many relationship.
Understanding One2many Relationships
Parent-Child Structure
One parent record links to multiple child records. Parent: Event Registration. Children: Attendees. The child has a Many2one back to parent.
Command Tuple Format
Odoo uses (0, 0, vals) tuple to create child records during parent creation. The first 0 means "create", second is ID (unused for create).
Dynamic Table Rows
JavaScript handles adding/removing table rows. On submit, loop through rows, collect values, and structure as array for the One2many field.
Module Structure
Implementation Steps
Define Parent and Child Models
Create the Event Registration (parent) and Attendee (child) models with One2many/Many2one relationship.
from odoo import models, fields, api
class EventRegistration(models.Model):
_name = 'event.registration.custom'
_description = 'Event Registration'
name = fields.Char(string='Registration Reference', readonly=True,
default=lambda self: 'New')
event_id = fields.Many2one('event.event', string='Event', required=True)
organizer_name = fields.Char(string='Organizer Name', required=True)
organizer_email = fields.Char(string='Organizer Email', required=True)
organizer_phone = fields.Char(string='Phone')
registration_date = fields.Date(string='Registration Date',
default=fields.Date.today)
notes = fields.Text(string='Special Requests')
state = fields.Selection([
('draft', 'Draft'),
('confirmed', 'Confirmed'),
('cancelled', 'Cancelled'),
], string='Status', default='draft')
# One2many field - links to multiple attendees
attendee_ids = fields.One2many(
'event.attendee.custom',
'registration_id',
string='Attendees'
)
attendee_count = fields.Integer(
string='Attendee Count',
compute='_compute_attendee_count'
)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', 'New') == 'New':
vals['name'] = self.env['ir.sequence'].next_by_code(
'event.registration.custom'
) or 'New'
return super().create(vals_list)
@api.depends('attendee_ids')
def _compute_attendee_count(self):
for record in self:
record.attendee_count = len(record.attendee_ids)
class EventAttendee(models.Model):
_name = 'event.attendee.custom'
_description = 'Event Attendee'
# Many2one back to parent
registration_id = fields.Many2one(
'event.registration.custom',
string='Registration',
ondelete='cascade'
)
name = fields.Char(string='Full Name', required=True)
email = fields.Char(string='Email', required=True)
phone = fields.Char(string='Phone')
dietary_preference = fields.Selection([
('none', 'No Preference'),
('vegetarian', 'Vegetarian'),
('vegan', 'Vegan'),
('gluten_free', 'Gluten Free'),
('halal', 'Halal'),
('kosher', 'Kosher'),
], string='Dietary Preference', default='none')
accessibility_needs = fields.Text(string='Accessibility Needs')
# Link to event for domain filtering
event_id = fields.Many2one(
related='registration_id.event_id',
store=True
)
Create Website Menu
Add a menu item to make the registration form accessible from the website.
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="event_registration_website_menu" model="website.menu">
<field name="name">Register for Event</field>
<field name="url">/event/register</field>
<field name="parent_id" ref="website.main_menu"/>
<field name="sequence" type="int">60</field>
</record>
</odoo>
Build the Website Form Template
Create the HTML form with a dynamic table for attendees.
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<template id="event_registration_form_template">
<t t-call="website.layout">
<div id="wrap" class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="card shadow">
<div class="card-header bg-primary text-white">
<h3 class="mb-0">
<i class="fa fa-calendar-check-o me-2"/>
Event Registration
</h3>
</div>
<div class="card-body">
<form id="registration_form">
<input type="hidden" name="csrf_token"
t-att-value="request.csrf_token()"/>
<!-- Event Selection -->
<div class="row mb-4">
<div class="col-md-6">
<label class="form-label fw-bold">
Select Event <span class="text-danger">*</span>
</label>
<select name="event_id" id="event_id"
class="form-select" required="required">
<option value="">Choose an event...</option>
<t t-foreach="events" t-as="event">
<option t-att-value="event.id">
<t t-esc="event.name"/> -
<t t-esc="event.date_begin" t-options="{'widget': 'date'}"/>
</option>
</t>
</select>
</div>
<div class="col-md-6">
<label class="form-label fw-bold">Registration Date</label>
<input type="date" name="registration_date"
class="form-control"
t-att-value="today"/>
</div>
</div>
<!-- Organizer Details -->
<h5 class="border-bottom pb-2 mb-3">
<i class="fa fa-user me-2"/>Organizer Information
</h5>
<div class="row mb-4">
<div class="col-md-4">
<label class="form-label">
Name <span class="text-danger">*</span>
</label>
<input type="text" name="organizer_name"
class="form-control" required="required"
placeholder="Your full name"/>
</div>
<div class="col-md-4">
<label class="form-label">
Email <span class="text-danger">*</span>
</label>
<input type="email" name="organizer_email"
class="form-control" required="required"
placeholder="email@example.com"/>
</div>
<div class="col-md-4">
<label class="form-label">Phone</label>
<input type="tel" name="organizer_phone"
class="form-control"
placeholder="+1 555-123-4567"/>
</div>
</div>
<!-- Attendees Table (One2many) -->
<h5 class="border-bottom pb-2 mb-3">
<i class="fa fa-users me-2"/>Attendees
</h5>
<div class="table-responsive">
<table class="table table-bordered" id="attendees_table">
<thead class="table-light">
<tr>
<th>Name *</th>
<th>Email *</th>
<th>Phone</th>
<th>Dietary Preference</th>
<th width="5%"></th>
</tr>
</thead>
<tbody>
<tr class="attendee_row">
<td>
<input type="text" name="attendee_name"
class="form-control form-control-sm"
placeholder="Full name" required="required"/>
</td>
<td>
<input type="email" name="attendee_email"
class="form-control form-control-sm"
placeholder="Email" required="required"/>
</td>
<td>
<input type="tel" name="attendee_phone"
class="form-control form-control-sm"
placeholder="Phone"/>
</td>
<td>
<select name="dietary_preference"
class="form-select form-select-sm">
<option value="none">No Preference</option>
<option value="vegetarian">Vegetarian</option>
<option value="vegan">Vegan</option>
<option value="gluten_free">Gluten Free</option>
<option value="halal">Halal</option>
<option value="kosher">Kosher</option>
</select>
</td>
<td class="text-center">
<button type="button"
class="btn btn-sm btn-outline-danger remove_attendee">
<i class="fa fa-trash"/>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<button type="button" class="btn btn-outline-primary mb-4"
id="add_attendee">
<i class="fa fa-plus me-1"/> Add Attendee
</button>
<!-- Notes -->
<div class="mb-4">
<label class="form-label">Special Requests</label>
<textarea name="notes" class="form-control" rows="3"
placeholder="Any special requirements..."/>
</div>
<!-- Submit -->
<button type="button" class="btn btn-success btn-lg w-100"
id="submit_registration">
<i class="fa fa-check me-2"/>Submit Registration
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</t>
</template>
</odoo>
Create Controller for Page and Submission
Handle page rendering and form submission with One2many data processing.
from odoo import http
from odoo.http import request
from datetime import date
class EventRegistrationController(http.Controller):
@http.route('/event/register', type='http', auth='public', website=True)
def registration_form(self):
"""Render the event registration form."""
events = request.env['event.event'].sudo().search([
('date_begin', '>=', date.today().isoformat())
])
return request.render(
'event_registration_website.event_registration_form_template',
{
'events': events,
'today': date.today().isoformat(),
}
)
@http.route('/event/register/submit', type='json', auth='public',
website=True, csrf=False)
def submit_registration(self, **post):
"""Handle form submission with One2many attendees."""
try:
# Extract parent record data
registration_vals = {
'event_id': int(post.get('event_id')),
'organizer_name': post.get('organizer_name'),
'organizer_email': post.get('organizer_email'),
'organizer_phone': post.get('organizer_phone') or False,
'registration_date': post.get('registration_date'),
'notes': post.get('notes') or False,
}
# Process One2many attendees using Command tuples
attendee_lines = []
attendees = post.get('attendee_ids', [])
for attendee in attendees:
# (0, 0, vals) creates a new child record
attendee_lines.append((0, 0, {
'name': attendee.get('name'),
'email': attendee.get('email'),
'phone': attendee.get('phone') or False,
'dietary_preference': attendee.get('dietary_preference', 'none'),
}))
registration_vals['attendee_ids'] = attendee_lines
# Create the registration with all attendees
registration = request.env['event.registration.custom'].sudo().create(
registration_vals
)
return {
'success': True,
'registration_id': registration.id,
'registration_name': registration.name,
'message': f'Registration {registration.name} created with '
f'{len(attendees)} attendee(s).'
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
Command Tuple: The (0, 0, vals) syntax creates child records. First 0 = create operation, second 0 = unused virtual ID, vals = dictionary of field values.
Write JavaScript for Dynamic Rows
Handle adding/removing attendee rows and form submission via RPC.
/** @odoo-module **/
import publicWidget from "@web/legacy/js/public/public_widget";
import { rpc } from "@web/core/network/rpc";
publicWidget.registry.EventRegistrationForm = publicWidget.Widget.extend({
selector: "#registration_form",
events: {
'click #add_attendee': '_onAddAttendee',
'click .remove_attendee': '_onRemoveAttendee',
'click #submit_registration': '_onSubmit',
},
_onAddAttendee: function(ev) {
ev.preventDefault();
// Clone the first row
const $firstRow = $('#attendees_table tbody tr.attendee_row:first');
const $newRow = $firstRow.clone();
// Clear all input values
$newRow.find('input').val('');
$newRow.find('select').val('none');
// Append to table
$('#attendees_table tbody').append($newRow);
},
_onRemoveAttendee: function(ev) {
ev.preventDefault();
const rowCount = $('#attendees_table tbody tr.attendee_row').length;
if (rowCount > 1) {
$(ev.currentTarget).closest('tr').remove();
} else {
alert('At least one attendee is required.');
}
},
_onSubmit: async function(ev) {
ev.preventDefault();
// Validate required fields
const eventId = $('#event_id').val();
const organizerName = $('input[name="organizer_name"]').val();
const organizerEmail = $('input[name="organizer_email"]').val();
if (!eventId || !organizerName || !organizerEmail) {
alert('Please fill in all required fields.');
return;
}
// Collect attendee data from table rows
const attendees = [];
let hasError = false;
$('#attendees_table tbody tr.attendee_row').each(function() {
const name = $(this).find('input[name="attendee_name"]').val();
const email = $(this).find('input[name="attendee_email"]').val();
const phone = $(this).find('input[name="attendee_phone"]').val();
const dietary = $(this).find('select[name="dietary_preference"]').val();
if (!name || !email) {
hasError = true;
return false; // break loop
}
attendees.push({
name: name,
email: email,
phone: phone,
dietary_preference: dietary,
});
});
if (hasError) {
alert('Please fill in name and email for all attendees.');
return;
}
// Prepare payload
const payload = {
event_id: eventId,
organizer_name: organizerName,
organizer_email: organizerEmail,
organizer_phone: $('input[name="organizer_phone"]').val(),
registration_date: $('input[name="registration_date"]').val(),
notes: $('textarea[name="notes"]').val(),
attendee_ids: attendees,
};
try {
const response = await rpc('/event/register/submit', payload);
if (response.success) {
alert(response.message);
window.location.href = '/';
} else {
alert('Error: ' + response.error);
}
} catch (error) {
console.error('Submission error:', error);
alert('Failed to submit registration. Please try again.');
}
},
});
export default publicWidget.registry.EventRegistrationForm;
Register Assets in Manifest
Include JavaScript in the website frontend bundle.
{
'name': 'Event Registration Website',
'version': '18.0.1.0.0',
'category': 'Website',
'summary': 'Event registration form with One2many attendees',
'depends': ['website', 'event'],
'data': [
'security/ir.model.access.csv',
'views/menu.xml',
'views/templates.xml',
],
'assets': {
'web.assets_frontend': [
'event_registration_website/static/src/js/registration_form.js',
],
},
'installable': True,
'license': 'LGPL-3',
}
Conclusion
Website One2many forms require three components working together: an HTML table for the child rows, JavaScript for add/remove/submit functionality, and a controller that transforms the flat array into Command tuples. The (0, 0, vals) format tells Odoo to create child records during parent creation. Apply this pattern to any parent-child relationship: invoices with lines, orders with items, surveys with questions.
Key Takeaways: Clone table rows for new entries. Collect row data into an array of objects. Use (0, 0, vals) Command tuples in the controller. Submit via JSON-RPC to bypass standard form handling. Handle validation in JavaScript before submission.
