📋 What You Will Learn
- The difference between TransientModel and Model
- How to define a wizard Python class
- Creating a wizard form view with a dialog target
- Triggering the wizard from a button on another model
- Returning actions from wizard execute methods
What is a Wizard?
A wizard is a form that pops up as a dialog window, collects some input from the user, then executes an action when the user clicks a button (typically labelled "Confirm" or "Apply"). Common uses include:
- Confirming a destructive action with a reason or note field
- Bulk processing selected records (e.g. marking 50 loans as returned at once)
- Collecting parameters for a report or export
- Multi-step onboarding or configuration flows
Wizards are implemented as TransientModel — a model subclass whose database records are automatically garbage-collected by Odoo's scheduled vacuum job after a configurable timeout (default: 1 hour).
TransientModel vs Model
| Feature | models.Model | models.TransientModel |
|---|---|---|
| Data persistence | Permanent — stays until deleted | Temporary — auto-cleaned after session |
| Appears in menus | Usually yes | No — accessed only via action |
| Access rights | Full — defined in ir.model.access.csv | All internal users can create/read by default |
| Typical use | Business data (invoices, products…) | Popup forms, parameter collection, batch ops |
| Vacuum cleanup | Never automatic | Yes — records older than 1 hr are deleted |
Wizard Python Class
A wizard is just a Python class that inherits from models.TransientModel. It has fields for user input and a method (usually action_execute) that performs the work and returns an action dict:
from odoo import models, fields, api
from odoo.exceptions import UserError
import logging
_logger = logging.getLogger(__name__)
class LibraryBulkReturnWizard(models.TransientModel):
_name = 'library.bulk.return.wizard'
_description = 'Bulk Book Return Wizard'
# Wizard fields — what we collect from the user
return_date = fields.Date(
string='Return Date',
required=True,
default=fields.Date.today,
)
condition = fields.Selection([
('good', 'Good Condition'),
('damaged', 'Damaged'),
('lost', 'Lost'),
], string='Condition', required=True, default='good')
note = fields.Text(string='Internal Note')
# Reference back to the loans being returned
# (populated automatically when wizard is launched from a list)
loan_ids = fields.Many2many(
'library.loan',
string='Loans to Return',
)
@api.model
def default_get(self, fields_list):
"""Pre-populate loan_ids from the records selected in the list view."""
res = super().default_get(fields_list)
active_ids = self.env.context.get('active_ids', [])
if 'loan_ids' in fields_list and active_ids:
res['loan_ids'] = [(6, 0, active_ids)]
return res
def action_execute(self):
"""Mark all selected loans as returned and close the dialog."""
self.ensure_one()
if not self.loan_ids:
raise UserError("No loans selected to return.")
for loan in self.loan_ids:
if loan.state not in ('active', 'overdue'):
continue
loan.write({
'state': 'returned',
'return_date': self.return_date,
'return_condition': self.condition,
'return_note': self.note,
})
_logger.info("Loan %d returned on %s", loan.id, self.return_date)
# Return an action to reload the originating list view
return {
'type': 'ir.actions.act_window',
'res_model': 'library.loan',
'view_mode': 'list,form',
'name': 'Loans',
}
def action_cancel(self):
"""Just close the dialog without doing anything."""
return {'type': 'ir.actions.act_window_close'}
Wizard View (Form Dialog)
The wizard view is a standard <form> view. The target: 'new' in the action (not in the view itself) is what makes it open as a dialog. Keep wizard forms concise — they should show only what the user needs to fill in:
<odoo>
<record id="view_library_bulk_return_wizard_form" model="ir.ui.view">
<field name="name">library.bulk.return.wizard.form</field>
<field name="model">library.bulk.return.wizard</field>
<field name="arch" type="xml">
<form string="Return Books">
<group>
<field name="return_date"/>
<field name="condition"/>
</group>
<group string="Loans Being Returned">
<field name="loan_ids" widget="many2many_tags" readonly="1"/>
</group>
<group>
<field name="note" placeholder="Optional internal note…"/>
</group>
<footer>
<!-- Primary action button -->
<button name="action_execute"
string="Confirm Return"
type="object"
class="btn-primary"/>
<!-- Cancel discards the wizard record -->
<button string="Cancel"
class="btn-secondary"
special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>
The <footer> element renders buttons in the dialog's footer bar. special="cancel" closes the dialog without calling any Python method — it discards the wizard record automatically.
Triggering a Wizard from a Button
There are two common ways to open a wizard: from a list view's Action menu (good for bulk operations on selected records) or from a button on a form view.
From the Action Menu (List View)
Define an ir.actions.act_window and bind it to the model:
<odoo>
<record id="action_bulk_return_wizard" model="ir.actions.act_window">
<field name="name">Return Selected Books</field>
<field name="res_model">library.bulk.return.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field> <!-- 'new' opens as popup dialog -->
<!-- Bind to the loan model so it appears in Actions dropdown -->
<field name="binding_model_id" ref="model_library_loan"/>
<field name="binding_view_types">list</field> <!-- show only in list view -->
</record>
</odoo>
From a Form View Button
A button on a form calls a Python method on the current model that creates a wizard record and returns an action:
<!-- In the library.loan form view -->
<button name="action_open_return_wizard"
string="Return This Book"
type="object"
class="btn-primary"
attrs="{'invisible': [('state', '!=', 'active')]}"/>
class LibraryLoan(models.Model):
_inherit = 'library.loan'
def action_open_return_wizard(self):
"""Open the return wizard pre-populated with this single loan."""
self.ensure_one()
wizard = self.env['library.bulk.return.wizard'].create({
'loan_ids': [(6, 0, self.ids)],
})
return {
'type': 'ir.actions.act_window',
'res_model': 'library.bulk.return.wizard',
'view_mode': 'form',
'res_id': wizard.id,
'target': 'new',
}
Wizard Return Actions
The method called by the wizard's confirm button should return an action dict. Here are the most common return values:
def action_execute(self):
# ... do the work ...
# Option 1: Close the dialog and do nothing else
return {'type': 'ir.actions.act_window_close'}
# Option 2: Navigate to a list of the affected records
return {
'type': 'ir.actions.act_window',
'name': 'Returned Loans',
'res_model': 'library.loan',
'view_mode': 'list,form',
'domain': [('id', 'in', self.loan_ids.ids)],
}
# Option 3: Navigate to a single record's form view
return {
'type': 'ir.actions.act_window',
'res_model': 'library.loan',
'view_mode': 'form',
'res_id': new_loan.id,
'target': 'current', # opens in the main window, not a popup
}
# Option 4: Reload the parent form (useful when wizard modified the parent record)
return {
'type': 'ir.actions.client',
'tag': 'reload',
}
Summary
📋 Key Points
models.TransientModelcreates temporary records automatically cleaned up by Odoo's vacuum- Wizard forms open as dialog popups when the action has
target: 'new' - Use
default_getto pre-populate wizard fields fromcontext['active_ids'] - The execute method does the work and returns an action dict to control what happens next
special="cancel"on a footer button closes the dialog and discards the wizard record cleanly- Bind the wizard action to a model with
binding_model_idto add it to the Actions menu - Return
{'type': 'ir.actions.act_window_close'}to simply dismiss the dialog
FAQ
By default, Odoo's automated vacuum job deletes transient model records older than 1 hour. This is controlled by the base_setup.default_transient_age_override system parameter. You can also call self.env['library.bulk.return.wizard']._transient_clean_rows_older(3600) manually. For very large databases, keeping transient tables small improves performance.
Yes. Use Odoo's notebook/tab widget to create a multi-tab form, or use the page field (an Integer or Selection on the wizard model) combined with attrs visibility rules to show/hide different groups of fields. Each "Next" button writes to the page field and the view dynamically shows the next set of inputs. Alternatively, the first wizard's confirm button can return an action that opens a second wizard.
TransientModel records are accessible to all internal users by default — you don't need to add an entry in ir.model.access.csv for basic read/create/write access. However, if you want to restrict which user groups can even open the wizard, you should add a groups attribute to the action that opens it, or check permissions inside the action_execute method.
target: 'new' opens the view in a popup dialog window, overlaid on top of the current view. target: 'current' (the default) replaces the current view in the main content area. Use 'new' for wizards and confirmation dialogs, and 'current' for normal navigation between records and views.