Create Dynamic One2Many Web Forms in Odoo 18
By Braincuber Team
Published on February 3, 2026
Creating web forms in Odoo is straightforward for simple data. But when you need to capture complex, multi-line data—like an expense report with multiple receipts, or a purchase request with multiple items—standard forms fall short. You need a One2Many form interface that allows users to dynamically add, edit, and remove rows directly on the website.
In this tutorial, we will build a complete "Employee Expense Claim" system. Employees can fill out a header form (employee name, date) and dynamically add multiple expense lines (description, amount, date) before submitting everything as a single record.
Dynamic Rows
Add and remove table rows instantly using JavaScript without page reloads.
Backend Link
Automatically creates parent and child records in Odoo backend via One2Many inputs.
JSON Validation
Validates data structure including types (integer, float) before creation.
Smart UI
Uses table layout for clean data entry, similar to Excel or Odoo backend views.
Step 1: Define the Backend Models
We need two models: the main Expense Claim (Parent) and the Expense Line (Child).
from odoo import models, fields, api
class ExpenseClaim(models.Model):
_name = 'expense.claim'
_description = 'Employee Expense Claim'
employee_id = fields.Many2one('res.users', string="Employee", required=True)
claim_date = fields.Date(string="Date", default=fields.Date.today, required=True)
description = fields.Char(string="Description")
line_ids = fields.One2many('expense.claim.line', 'claim_id', string="Expenses")
class ExpenseClaimLine(models.Model):
_name = 'expense.claim.line'
_description = 'Expense Line'
claim_id = fields.Many2one('expense.claim', string="Claim Reference")
description = fields.Char(string="Description", required=True)
amount = fields.Float(string="Amount", required=True)
expense_date = fields.Date(string="Expense Date")
Step 2: Create Controller & Route
We need a controller to render the form page and fetch necessary data (like the current user).
from odoo import http
from odoo.http import request
class ExpenseController(http.Controller):
@http.route('/expense/new', auth='user', website=True)
def expense_form(self, **kwargs):
return request.render('expense_module.expense_form_template', {
'user': request.env.user
})
@http.route('/expense/submit', type='json', auth='user', website=True)
def expense_submit(self, **post):
# We will implement this logic in Step 4
return self._handle_submission(post)
Step 3: Web Template (XML)
This is the frontend structure. We create a form with a main section and a dynamic table for the One2Many lines.
<template id="expense_form_template" name="New Expense Claim">
<t t-call="website.layout">
<div id="wrap" class="oe_structure">
<div class="container py-5">
<h1 class="mb-4">Submit Expense Claim</h1>
<form id="expense_form">
<!-- Values for Parent Record -->
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Employee</label>
<input type="text" class="form-control"
t-att-value="user.name" readonly="1"/>
<input type="hidden" id="employee_id"
t-att-value="user.id"/>
</div>
<div class="col-md-6">
<label class="form-label">Date</label>
<input type="date" id="claim_date" class="form-control"
required="1"/>
</div>
</div>
<!-- Dynamic One2Many Table -->
<h4 class="mt-4">Expense Lines</h4>
<table class="table table-bordered" id="expense_table">
<thead class="table-light">
<tr>
<th>Description</th>
<th>Date</th>
<th>Amount</th>
<th width="50"></th>
</tr>
</thead>
<tbody>
<tr class="expense_line">
<td>
<input type="text" name="description"
class="form-control" required="1"/>
</td>
<td>
<input type="date" name="expense_date"
class="form-control"/>
</td>
<td>
<input type="number" step="0.01" name="amount"
class="form-control" required="1"/>
</td>
<td class="text-center">
<button type="button" class="btn btn-sm btn-danger remove_line">
<i class="fa fa-trash"/>
</button>
</td>
</tr>
</tbody>
</table>
<button type="button" class="btn btn-secondary mb-3 add_line">
<i class="fa fa-plus"/> Add Line
</button>
<div class="text-end">
<button type="button" class="btn btn-primary btn-lg submit_form">
Submit Claim
</button>
</div>
</form>
</div>
</div>
</t>
</template>
Step 4: JavaScript Logic & Backend Processing
This is where the magic happens. We use JavaScript to handle row addition/removal and gather all data into a JSON structure to send to the server.
/** @odoo-module **/
import publicWidget from "@web/legacy/js/public/public_widget";
import { rpc } from "@web/core/network/rpc";
publicWidget.registry.ExpenseForm = publicWidget.Widget.extend({
selector: '#expense_form',
events: {
'click .add_line': '_onAddLine',
'click .remove_line': '_onRemoveLine',
'click .submit_form': '_onSubmit',
},
_onAddLine: function (ev) {
// Clone the first row to create a new one
var $firstRow = this.$('#expense_table tbody tr:first');
var $newRow = $firstRow.clone();
// Reset values in the new row
$newRow.find('input').val('');
// Append to table
this.$('#expense_table tbody').append($newRow);
},
_onRemoveLine: function (ev) {
var $rows = this.$('#expense_table tbody tr');
if ($rows.length > 1) {
$(ev.currentTarget).closest('tr').remove();
} else {
alert("You must have at least one expense line.");
}
},
_onSubmit: async function (ev) {
ev.preventDefault();
// 1. Gather Parent Data
var expenseData = {
employee_id: parseInt(this.$('#employee_id').val()),
claim_date: this.$('#claim_date').val(),
line_ids: [] // One2Many container
};
// 2. Gather Child (One2Many) Data
var self = this;
this.$('.expense_line').each(function () {
var $row = $(this);
var lineData = {
description: $row.find('input[name="description"]').val(),
expense_date: $row.find('input[name="expense_date"]').val(),
amount: parseFloat($row.find('input[name="amount"]').val()) || 0.0,
};
// Odoo 'create' command structure: (0, 0, {values})
// We'll prepare raw dicts here and handle the tuple in Python
expenseData.line_ids.push(lineData);
});
// 3. Send to Backend
try {
var result = await rpc('/expense/submit', expenseData);
if (result.success) {
window.location.href = "/expense/thank-you";
} else {
alert("Error: " + result.error);
}
} catch (error) {
console.error(error);
alert("Failed to submit form.");
}
}
});
def _handle_submission(self, post):
try:
# Prepare One2Many lines for creation
# Structure: [(0, 0, {values}), (0, 0, {values})]
lines_command = []
for line in post.get('line_ids', []):
lines_command.append((0, 0, line))
# Create the main record
claim = request.env['expense.claim'].sudo().create({
'employee_id': post.get('employee_id'),
'claim_date': post.get('claim_date'),
'line_ids': lines_command
})
return {'success': True, 'id': claim.id}
except Exception as e:
return {'success': False, 'error': str(e)}
Odoo uses special tuples for One2Many operations. (0, 0, {values}) tells the Odoo ORM to "CREATE a new record" in the related model with the provided dictionary of values. This connects the new expense lines to the claim automatically upon creation.
You have successfully built a dynamic One2Many form! Employees can now visit /expense/new, add as many expense rows as needed, and submit them. The JavaScript handles the array manipulation, and the Odoo controller handles the relational record creation in a single transaction.
Frequently Asked Questions
You can make an input read-only by adding the `readonly='1'` attribute to the HTML input tag. In the tutorial example, user details are often set to read-only so the logged-in user cannot modify their own employee reference.
Yes, but it requires handling `multipart/form-data`. In standard JSON AJAX requests, file data isn't sent automatically. You would need to use `FormData` in JavaScript to append files and process the `http.request.post` data in the controller differently, using `base64` encoding to store the attachment in Odoo.
Odoo uses the special command syntax `(0, 0, {values})` during the `create()` method. When you pass a list of these commands to a One2Many field (like `line_ids`), Odoo creates the child records and automatically sets their Many2one link (`claim_id`) to the newly created parent record ID.
Yes, but the logic changes slightly. You would need to pass the existing record ID to the template. In the controller, instead of `create()`, you would use `write()`. For One2Many lines, you'd need to handle updates `(1, id, values)`, deletions `(2, id, 0)`, and additions `(0, 0, values)` based on what the user changed.
If you use `auth='user'`, Odoo ensures the user is logged in. However, creating records usually requires Access Rights. By using `.sudo()` in the controller (`request.env['model'].sudo().create(...)`), you bypass record rules, which is common for public or portal forms. Be careful with `.sudo()` and ensure you validate input data to prevent security risks.
