Why State Machines Matter in Odoo
Almost every business process in Odoo follows a state machine pattern: quotations become sales orders, draft invoices become posted, leads progress through a pipeline. Understanding how to implement state machines correctly is fundamental to building reliable Odoo modules.
A state machine defines a set of states and the allowed transitions between them. In Odoo, this pattern is implemented using Selection fields, Python methods for transitions, and XML views with statusbar widgets.
Defining States with Selection Fields
The foundation of any Odoo state machine is a Selection field that holds the current state:
from odoo import models, fields, api
from odoo.exceptions import UserError
class MaintenanceRequest(models.Model):
_name = 'maintenance.request'
_description = 'Maintenance Request'
state = fields.Selection([
('draft', 'Draft'),
('submitted', 'Submitted'),
('approved', 'Approved'),
('in_progress', 'In Progress'),
('done', 'Done'),
('cancelled', 'Cancelled'),
], string='Status', default='draft',
required=True, tracking=True, copy=False)
Key points: use tracking=True so chatter logs state changes, copy=False so duplicated records start fresh, and required=True so the state is never empty.
Transition Methods
Each state transition should be a separate method. This gives you a clear place to add validation, side effects, and access control:
def action_submit(self):
for record in self:
if record.state != 'draft':
raise UserError('Only draft requests can be submitted.')
if not record.description:
raise UserError('Please add a description before submitting.')
self.write({'state': 'submitted'})
def action_approve(self):
for record in self:
if record.state != 'submitted':
raise UserError('Only submitted requests can be approved.')
self.write({
'state': 'approved',
'approved_date': fields.Datetime.now(),
'approved_by': self.env.uid,
})
def action_start(self):
self.filtered(lambda r: r.state == 'approved').write(
{'state': 'in_progress'})
def action_done(self):
self.filtered(lambda r: r.state == 'in_progress').write({
'state': 'done',
'completion_date': fields.Datetime.now(),
})
def action_cancel(self):
cancelable = self.filtered(
lambda r: r.state not in ('done', 'cancelled'))
cancelable.write({'state': 'cancelled'})
Notice two patterns here: strict validation with UserError for critical transitions, and lenient filtering with filtered() for batch operations where some records may not be in the right state.
Defining the Transition Map
For complex workflows, define allowed transitions explicitly:
_TRANSITIONS = {
'draft': ['submitted', 'cancelled'],
'submitted': ['approved', 'cancelled'],
'approved': ['in_progress', 'cancelled'],
'in_progress': ['done', 'cancelled'],
'done': [],
'cancelled': ['draft'], # allow reset to draft
}
def _check_transition(self, new_state):
for record in self:
allowed = self._TRANSITIONS.get(record.state, [])
if new_state not in allowed:
raise UserError(
f'Cannot move from {record.state} to {new_state}.')
def action_change_state(self, new_state):
self._check_transition(new_state)
self.write({'state': new_state})
XML View with Statusbar
The statusbar widget renders states as a clickable progress bar in the form view:
<form>
<header>
<button name="action_submit" type="object"
string="Submit" class="btn-primary"
invisible="state != 'draft'"/>
<button name="action_approve" type="object"
string="Approve" class="btn-primary"
invisible="state != 'submitted'"
groups="base.group_manager"/>
<button name="action_start" type="object"
string="Start Work" class="btn-primary"
invisible="state != 'approved'"/>
<button name="action_done" type="object"
string="Mark Done" class="btn-primary"
invisible="state != 'in_progress'"/>
<button name="action_cancel" type="object"
string="Cancel" class="btn-secondary"
invisible="state in ('done', 'cancelled')"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,submitted,approved,in_progress,done"/>
</header>
<!-- form body -->
</form>
The statusbar_visible attribute controls which states appear in the progress bar. Cancelled is excluded so the bar shows only the happy path.
Conditional Field Visibility
Fields often depend on the current state. Use invisible attributes to show fields only when relevant:
<field name="approved_by" invisible="state not in ('approved', 'in_progress', 'done')"/>
<field name="rejection_reason" invisible="state != 'cancelled'"/>
Access Control on Transitions
Use the groups attribute on buttons to restrict who can trigger transitions. For programmatic checks:
def action_approve(self):
if not self.env.user.has_group('module.group_manager'):
raise UserError('Only managers can approve requests.')
self._check_transition('approved')
self.write({'state': 'approved'})
Record Rules by State
You can restrict access based on state using record rules:
<record model="ir.rule" id="rule_draft_own_only">
<field name="name">Users see only own drafts</field>
<field name="model_id" ref="model_maintenance_request"/>
<field name="domain_force">
['|', ('state', '!=', 'draft'),
('create_uid', '=', user.id)]
</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
</record>
Automated Transitions with Cron
Some transitions should happen automatically. For example, auto-cancelling stale requests:
def _cron_cancel_stale_requests(self):
cutoff = fields.Datetime.subtract(fields.Datetime.now(), days=30)
stale = self.search([
('state', '=', 'submitted'),
('write_date', '<', cutoff),
])
stale.action_cancel()
Testing State Machines
Always test every transition path, including invalid ones:
def test_cannot_approve_draft(self):
request = self.env['maintenance.request'].create({
'name': 'Test', 'state': 'draft'})
with self.assertRaises(UserError):
request.action_approve()
def test_happy_path(self):
request = self.env['maintenance.request'].create({
'name': 'Test', 'description': 'Fix pump'})
request.action_submit()
self.assertEqual(request.state, 'submitted')
request.action_approve()
self.assertEqual(request.state, 'approved')
Common Pitfalls
- Forgetting copy=False: Duplicated records inherit the original state, skipping validation.
- Not using tracking=True: State changes become invisible in the audit trail.
- Mixing write and assignment: Always use
self.write()for state changes to trigger computed fields and onchanges correctly. - Hardcoding states in SQL: If you query states via raw SQL, you bypass Odoo's ORM and record rules.