Skip to content

Odoo Wizards (TransientModel): Complete Developer Guide

DeployMonkey Team · March 22, 2026 13 min read

What Is a Wizard?

A wizard is a temporary form that collects user input and performs an action. Unlike regular models (stored permanently), wizards use TransientModel — records are automatically deleted after a period. Common uses: confirmation dialogs, bulk updates, data imports, and multi-step processes.

Basic Wizard

# models/equipment_assign_wizard.py
from odoo import api, fields, models


class EquipmentAssignWizard(models.TransientModel):
    _name = 'equipment.assign.wizard'
    _description = 'Assign Equipment Wizard'

    equipment_id = fields.Many2one(
        'equipment.item', string='Equipment', required=True,
    )
    employee_id = fields.Many2one(
        'hr.employee', string='Assign To', required=True,
    )
    assignment_date = fields.Date(
        string='Assignment Date', default=fields.Date.today,
    )
    notes = fields.Text(string='Notes')

    def action_assign(self):
        """Assign equipment to employee."""
        self.ensure_one()
        self.equipment_id.write({
            'assigned_to': self.employee_id.id,
            'status': 'in_use',
        })
        # Post message on equipment
        self.equipment_id.message_post(
            body=f"Assigned to {self.employee_id.name} on {self.assignment_date}",
        )
        return {'type': 'ir.actions.act_window_close'}

Wizard View

<record id="equipment_assign_wizard_form" model="ir.ui.view">
    <field name="name">equipment.assign.wizard.form</field>
    <field name="model">equipment.assign.wizard</field>
    <field name="arch" type="xml">
        <form string="Assign Equipment">
            <group>
                <field name="equipment_id"/>
                <field name="employee_id"/>
                <field name="assignment_date"/>
                <field name="notes"/>
            </group>
            <footer>
                <button string="Assign" type="object" name="action_assign"
                        class="btn-primary"/>
                <button string="Cancel" class="btn-secondary" special="cancel"/>
            </footer>
        </form>
    </field>
</record>

<record id="equipment_assign_wizard_action" model="ir.actions.act_window">
    <field name="name">Assign Equipment</field>
    <field name="res_model">equipment.assign.wizard</field>
    <field name="view_mode">form</field>
    <field name="target">new</field>  <!-- Opens as dialog -->
</record>

Passing Context to Wizard

# Button on equipment form that opens wizard with pre-filled equipment:
<button name="%(equipment_assign_wizard_action)d" type="action"
        string="Assign" class="btn-primary"
        context="{'default_equipment_id': active_id}"/>

# In the wizard, equipment_id is auto-filled from context

Bulk Operation Wizard

class EquipmentBulkStatusWizard(models.TransientModel):
    _name = 'equipment.bulk.status.wizard'
    _description = 'Bulk Status Update'

    new_status = fields.Selection([
        ('available', 'Available'),
        ('maintenance', 'In Maintenance'),
        ('retired', 'Retired'),
    ], string='New Status', required=True)
    equipment_ids = fields.Many2many(
        'equipment.item', string='Equipment',
    )

    @api.model
    def default_get(self, fields_list):
        """Pre-fill with selected records from list view."""
        res = super().default_get(fields_list)
        if self.env.context.get('active_ids'):
            res['equipment_ids'] = [(6, 0, self.env.context['active_ids'])]
        return res

    def action_update(self):
        """Update status for all selected equipment."""
        self.equipment_ids.write({'status': self.new_status})
        return {'type': 'ir.actions.act_window_close'}

Multi-Step Wizard

class ImportWizard(models.TransientModel):
    _name = 'equipment.import.wizard'
    _description = 'Import Equipment'

    state = fields.Selection([
        ('upload', 'Upload File'),
        ('preview', 'Preview'),
        ('done', 'Done'),
    ], default='upload')
    file = fields.Binary(string='CSV File')
    filename = fields.Char()
    preview_html = fields.Html(string='Preview', readonly=True)
    imported_count = fields.Integer(readonly=True)

    def action_preview(self):
        """Parse file and show preview."""
        # Parse CSV, generate preview HTML
        self.state = 'preview'
        self.preview_html = '<table>...preview data...</table>'
        return self._reopen()

    def action_import(self):
        """Import the data."""
        # Create records from parsed data
        self.state = 'done'
        self.imported_count = 42
        return self._reopen()

    def _reopen(self):
        """Return action to refresh the wizard form."""
        return {
            'type': 'ir.actions.act_window',
            'res_model': self._name,
            'res_id': self.id,
            'view_mode': 'form',
            'target': 'new',
        }

Key Patterns

  • target='new' — Opens as dialog/popup (standard for wizards)
  • default_get — Pre-fill wizard from context (active_id, active_ids)
  • context={'default_field': value} — Pass defaults from button
  • act_window_close — Close dialog after action
  • _reopen() — Refresh wizard for multi-step

Security

# Wizards need ir.model.access too!
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_assign_wizard,equipment.assign.wizard,model_equipment_assign_wizard,base.group_user,1,1,1,1

Common Mistakes

  • Using Model instead of TransientModel — Records persist forever, bloating the database
  • Forgetting security — Wizards need ir.model.access entries
  • Not handling empty active_ids — Check context before using
  • Returning None — Always return an action dict or act_window_close