Quick Answer
Stop forcing users through 8 steps across 3 screens. Build multi-step wizards with TransientModels: bundle complex, multi-model operations into simple, linear popups. Standard way: 8 steps, high error rate. Wizard way: 1 popup, 4 clicks, Odoo does the rest. Save 2 minutes vs. 15-minute headache per task.
The Wizard Problem
Your D2C warehouse team needs to process a return.
The "Standard" Way
1. Open the Delivery Order
2. Click "Return"
3. Manually select items
4. Go to the new Receipt
5. Click "Validate"
6. Go to the Accounting menu
7. Create a Credit Note manually
8. Email the customer
Result: 8 steps, 3 different screens, high chance of human error (forgot to credit the shipping? forgot to email?).
The "Wizard" Way
1. Click "Process Return" button on the order
2. Popup appears asking: "Which items? Restock or Scrap?"
3. Next screen: "Issue Refund? (Yes/No)"
4. Click "Confirm" → Odoo creates return picking, validates it, posts credit note, emails customer
We've implemented 150+ Odoo systems. The ones that users love? They use wizards to bundle complex, multi-model operations into simple, linear workflows. The ones users hate? They force staff to jump between menus to do one logical task. That's the difference between a 2-minute task and a 15-minute headache.
What is a Wizard in Odoo?
A wizard is a TransientModel.
| Type | Storage | Use Case |
|---|---|---|
| models.Model | Permanent (stored until deleted) | sale.order, product.product |
| models.TransientModel | Temporary (auto-vacuumed by Odoo) | Dialog boxes, wizards |
The Architecture: A Multi-Step Wizard
We will build a "Mass Order Update" wizard that allows a manager to:
Step 1: Select a new shipping method and priority for selected orders
Step 2: Review the changes and add a custom note
Step 3: Confirm and apply updates in bulk
Step 1: Define the TransientModel (Python)
We use a state field to track which screen of the wizard the user is on.
from odoo import models, fields, api
class MassOrderUpdate(models.TransientModel):
_name = 'sale.mass.update.wizard'
_description = 'Mass Update Sale Orders'
# State determines which "page" of the wizard to show
state = fields.Selection([
('selection', 'Selection'),
('review', 'Review'),
], default='selection')
# STEP 1 FIELDS
target_order_ids = fields.Many2many('sale.order', string='Orders to Update')
new_priority = fields.Selection([
('0', 'Normal'),
('1', 'Urgent')
], string='New Priority')
new_shipping_id = fields.Many2one('delivery.carrier', string='New Shipping Method')
# STEP 2 FIELDS
note = fields.Text(string='Log Note', help='Note to add to chatter')
@api.model
def default_get(self, fields):
"""Pre-populate the wizard with selected orders from the list view."""
res = super(MassOrderUpdate, self).default_get(fields)
# Context contains 'active_ids' when triggered from a list view
active_ids = self.env.context.get('active_ids', [])
if active_ids:
res['target_order_ids'] = [(6, 0, active_ids)]
return res
def action_next(self):
"""Move to the next step (Review)."""
self.state = 'review'
# Return a window action to "refresh" the wizard view with new fields
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}
def action_back(self):
"""Move back to the previous step."""
self.state = 'selection'
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}
def action_apply(self):
"""Final Step: Apply changes to the real models."""
for order in self.target_order_ids:
# Apply changes
update_vals = {}
if self.new_priority:
update_vals['priority'] = self.new_priority
if self.new_shipping_id:
update_vals['carrier_id'] = self.new_shipping_id.id
order.write(update_vals)
# Log the change
if self.note:
order.message_post(body=self.note)
# Close the wizard
return {'type': 'ir.actions.act_window_close'}
Step 2: Define the View (XML)
The magic of multi-step wizards happens in the XML using the states attribute on groups.
<odoo>
<record id="view_mass_order_update_form" model="ir.ui.view">
<field name="name">sale.mass.update.wizard.form</field>
<field name="model">sale.mass.update.wizard</field>
<field name="arch" type="xml">
<form string="Mass Order Update">
<!-- Header / Progress Bar -->
<field name="state" invisible="1"/>
<sheet>
<!-- STEP 1: SELECTION -->
<!-- Only visible when state is 'selection' -->
<group states="selection" string="Update Options">
<field name="target_order_ids" widget="many2many_tags" readonly="1"/>
<field name="new_priority" widget="priority"/>
<field name="new_shipping_id"/>
</group>
<!-- STEP 2: REVIEW -->
<!-- Only visible when state is 'review' -->
<group states="review" string="Review & Log">
<div class="alert alert-info">
You are about to update <field name="target_order_ids" widget="statinfo"/> orders.
This action cannot be undone.
</div>
<field name="note" placeholder="Reason for update..."/>
</group>
</sheet>
<footer>
<!-- Buttons for Step 1 -->
<button string="Next" name="action_next" type="object"
class="btn-primary" states="selection"/>
<!-- Buttons for Step 2 -->
<button string="Back" name="action_back" type="object"
class="btn-secondary" states="review"/>
<button string="Confirm Update" name="action_apply" type="object"
class="btn-primary" states="review"/>
<!-- Cancel Button (Always visible) -->
<button string="Cancel" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- Action to open wizard -->
<record id="action_mass_order_update" model="ir.actions.act_window">
<field name="name">Mass Update Orders</field>
<field name="res_model">sale.mass.update.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_view_types">list</field>
</record>
</odoo>
Key Mechanics Explained
1. Context Passing (active_ids)
When you select records in a List View and click an "Action" menu item, Odoo automatically passes the IDs of the selected records in the context key active_ids.
In default_get, we grab these IDs and assign them to our Many2many field. This is how the wizard knows which orders to modify.
2. State Management
We don't actually navigate to a new URL. The action_next method simply changes the state field on the wizard record and returns a window action that re-opens the same record ('res_id': self.id).
Odoo re-renders the form. Because of the <group states="..."> attributes in XML, different fields appear. To the user, it looks like a new page.
3. target="new"
This setting in the ir.actions.act_window tells Odoo to open the view in a modal popup overlay, rather than the full screen.
4. special="cancel"
This button attribute closes the popup without calling any Python method. It's built-in Odoo functionality.
Advanced: Dynamic Domains with @api.onchange
Sometimes Step 2 depends heavily on Step 1.
Example: In Step 1, you pick a "Carrier." In Step 2, you want to pick a "Service Level," but only those available for that Carrier.
# Inside the wizard model
@api.onchange('new_shipping_id')
def _onchange_carrier(self):
"""Filter service levels based on selected carrier."""
if self.new_shipping_id:
return {
'domain': {
'service_level_id': [('carrier_id', '=', self.new_shipping_id.id)]
}
}
Real-World D2C Use Case: Split Shipment Wizard
Scenario: An order has 10 items. 5 are in stock, 5 are backordered. You want to split the order cleanly, notify the customer, and tag the new order as "Backorder."
Wizard Workflow
Selection: Shows list of lines on the current order. User ticks boxes for items to move.
Options: Checkbox "Send Email Notification?", Dropdown "Reason code".
Action: Creates new sale.order, moves selected lines, adjusts quantities, posts messages linking both orders.
This logic is too complex for a simple button. It requires user input. This is the perfect use case for a wizard.
Wizard Components Breakdown
| Component | Purpose | Implementation |
|---|---|---|
| TransientModel | Temporary data storage | models.TransientModel |
| state field | Track current step | fields.Selection |
| default_get | Pre-populate from context | @api.model method |
| states attribute | Show/hide groups per step | <group states="step1"> |
| target="new" | Open as popup modal | ir.actions.act_window |
Action Items: Build Your Wizard
Identify Complex Flows
❏ Find a process where your team currently has to visit 3+ screens to complete one task
❏ Find a process where "human error" is common due to forgetting a step
Build the Skeleton
❏ Create the TransientModel with a state field
❏ Create the XML form view with states="step1" and states="step2" groups
❏ Add the action to the binding_model_id to make it appear in the Action menu
Implement Logic
❏ Use active_ids to grab the context
❏ Write the "Action" method to perform the actual database writes
Frequently Asked Questions
What's the difference between models.Model and models.TransientModel?
models.Model: Permanent storage (sale.order, product.product). Data persists until deleted. models.TransientModel: Temporary storage (wizards, dialogs). Odoo automatically vacuums (deletes) old records periodically.
How do I pass selected records from a list view to a wizard?
Odoo automatically passes selected record IDs in context['active_ids']. In your wizard's default_get method, retrieve them: active_ids = self.env.context.get('active_ids', []) and assign to a Many2many field.
How do I create multi-step wizards with state management?
Add a state field (Selection type) to track current step. In XML, use <group states="step1"> to show/hide fields. Navigation methods change state and return window action with 'res_id': self.id to re-render the same wizard.
When should I use a wizard vs. a regular button action?
Use a wizard when you need user input across multiple steps (returns, split shipments, bulk updates). Use a button for simple, single-click actions with no user input required (confirm order, send email).
Free UX/Workflow Design Session
Stop forcing your team to be "human middleware" jumping between screens. We'll analyze your most painful manual workflows, prototype a multi-step wizard to solve it, and review the Python logic to ensure data integrity during the transition. Good software guides the user. Great software does the heavy lifting for them.
