Quick Answer
Stop chaotic processes where users skip critical steps. Simple way: Selection field, users change Draft → Refunded instantly. Result: warehouse forgets to inspect, finance refunds "Draft" returns, chaos. State Machine way: Enforce rules with guard clauses (cannot move to Received without tracking number, cannot Refund unless Inspected=Pass, only Managers can Approve). Buttons appear/disappear based on current state. Use Selection field with tracking=True, Python transition methods with validation, statusbar widget in XML, security groups. Result: predictable pipeline, mistakes prevented.
The Workflow Problem
Your D2C brand has a complex return process:
Draft: Customer requests return
Approved: Support team reviews and sends label
Received: Warehouse scans the package
Inspected: Quality check (Pass/Fail)
Refunded: Finance issues credit
Rejected: Item damaged, return denied
The "Simple" Way
You add a Selection field state with these options. Users can change it from "Draft" to "Refunded" instantly.
Result: Warehouse forgets to inspect. Finance refunds a "Draft" return. Chaos.
The "State Machine" Way
You enforce rules:
✓ Cannot move to "Received" until a tracking number exists
✓ Cannot move to "Refunded" unless "Inspected" = Pass
✓ Only Managers can move to "Approved"
✓ Buttons appear/disappear based on the current state
We've implemented 150+ Odoo systems. The most robust modules are built around strict State Machines. They guide the user. They prevent mistakes. They turn a chaotic process into a predictable pipeline.
Step 1: Defining the State Field
The heart of the machine is a Selection field named state.
from odoo import models, fields, api, _
from odoo.exceptions import UserError
class ReturnRequest(models.Model):
_name = 'return.request'
_description = 'Customer Return Request'
_inherit = ['mail.thread', 'mail.activity.mixin'] # Enable Chatter
state = fields.Selection([
('draft', 'Draft'),
('approved', 'Approved'),
('received', 'Received'),
('inspected', 'Inspected'),
('refunded', 'Refunded'),
('rejected', 'Rejected'),
], string='Status', default='draft', tracking=True, required=True)
# Other fields
tracking_number = fields.Char(string="Tracking #")
quality_check = fields.Selection([('pass', 'Pass'), ('fail', 'Fail')])
Best Practice: Always enable tracking=True on the state field. This logs every status change (and who did it) in the chatter automatically.
Step 2: Defining Transitions (The Buttons)
Never let users edit the state field directly. Make it readonly. Use buttons to trigger transitions.
def action_approve(self):
"""Move from Draft -> Approved"""
for rec in self:
if rec.state != 'draft':
continue # Idempotency check
rec.state = 'approved'
# Logic: Send Email with Label
def action_receive(self):
"""Move from Approved -> Received"""
for rec in self:
if not rec.tracking_number:
raise UserError(_("You must enter a tracking number before receiving."))
rec.state = 'received'
def action_inspect(self):
"""Move from Received -> Inspected"""
for rec in self:
if not rec.quality_check:
raise UserError(_("Please select Pass or Fail."))
if rec.quality_check == 'fail':
rec.state = 'rejected'
else:
rec.state = 'inspected'
def action_refund(self):
"""Move from Inspected -> Refunded"""
for rec in self:
if rec.state != 'inspected':
raise UserError(_("Only inspected items can be refunded."))
# Logic: Create Credit Note
rec.state = 'refunded'
def action_reset_draft(self):
"""Reset to Draft (for corrections)"""
for rec in self:
if rec.state == 'refunded':
raise UserError(_("Cannot reset a refunded return."))
rec.state = 'draft'
Key Concept: Guard Clauses - Notice the checks (if not rec.tracking_number). These are Guard Clauses. They prevent invalid transitions. This is the core value of a State Machine—enforcing business rules programmatically.
Step 3: The UI (Statusbar Widget)
Now, make it visual. Odoo's statusbar widget is iconic.
<form>
<header>
<!-- Buttons define the "Arrows" of the state machine -->
<!-- Visible only in Draft -->
<button name="action_approve" string="Approve Return" type="object"
class="oe_highlight" states="draft" groups="base.group_user"/>
<!-- Visible only in Approved -->
<button name="action_receive" string="Mark Received" type="object"
class="oe_highlight" states="approved"/>
<!-- Visible only in Received -->
<button name="action_inspect" string="Complete Inspection" type="object"
class="oe_highlight" states="received"/>
<!-- Visible only in Inspected -->
<button name="action_refund" string="Issue Refund" type="object"
class="oe_highlight" states="inspected" groups="account.group_account_manager"/>
<!-- Reset Button (Visible in specific states) -->
<button name="action_reset_draft" string="Reset to Draft" type="object"
states="approved,received,rejected"/>
<!-- The Status Bar itself -->
<field name="state" widget="statusbar"
statusbar_visible="draft,approved,received,inspected,refunded"/>
</header>
<sheet>
<!-- Make fields readonly in later stages to prevent tampering -->
<group>
<field name="tracking_number"
attrs="{'readonly': [('state', 'in', ['refunded', 'rejected'])]}"/>
<field name="quality_check"
attrs="{'invisible': [('state', 'in', ['draft', 'approved'])]}"/>
</group>
</sheet>
</form>
Attributes Explained
| Attribute | Purpose |
|---|---|
| widget="statusbar" | Renders the pipeline visual |
| statusbar_visible | Which states to display in the bar (for display purposes) |
| states="..." | Button only exists when record is in that state |
| class="oe_highlight" | Highlights the "Next Logical Step" button in purple |
Advanced: Preventing "Backdoor" Edits
If a clever user goes to "Edit" mode, can they just change the dropdown?
Fix: Add readonly="1" to the state field in the XML.
If they export data to CSV and re-import it with a new state?
Fix: Use Python constraints or write overrides.
@api.model
def create(self, vals):
# Force new records to be draft
vals['state'] = 'draft'
return super().create(vals)
def write(self, vals):
if 'state' in vals:
# Prevent manual writing to state unless via our specific methods
# (This is strict, but effective)
# You might implement a context flag check here.
pass
return super().write(vals)
Real-World D2C Scenario: Order Approval Workflow
Requirement
❏ Orders < $1000: Auto-confirm
❏ Orders > $1000: Must be approved by Manager
❏ Orders > $5000: Must be approved by VP
Implementation
Inherit sale.order: Add states to_approve, vp_approve.
Override action_confirm:
def action_confirm(self):
if self.amount_total > 5000:
self.state = 'vp_approve'
elif self.amount_total > 1000:
self.state = 'to_approve'
else:
super().action_confirm() # Standard logic
Add Buttons:
"Manager Approve" (visible in to_approve, group Sales Manager)
"VP Approve" (visible in vp_approve, group VP Sales)
State Machine Flow Diagram
| From State | Transition | Guard Clause | To State |
|---|---|---|---|
| Draft | action_approve() | None (automatic) | Approved |
| Approved | action_receive() | tracking_number required | Received |
| Received | action_inspect() | quality_check required | Inspected / Rejected |
| Inspected | action_refund() | state == 'inspected' | Refunded |
| Any (except Refunded) | action_reset_draft() | state != 'refunded' | Draft |
Action Items: Build Your State Machine
Map the Process
❏ Draw a flowchart. Circles = States. Arrows = Transitions.
❏ Label each arrow: "Who can do this?" and "What data is required?"
Code
❏ Define the Selection field
❏ Write the transition methods with Guard Clauses
❏ Update the Form View with the <header> and statusbar
Test
❏ Try to skip a step. (Should fail).
❏ Try to move backward. (Should fail or be restricted).
❏ Check the Chatter log
Frequently Asked Questions
What are Guard Clauses and why are they important?
Guard Clauses are validation checks in transition methods (e.g., if not rec.tracking_number: raise UserError()). They prevent invalid state transitions by enforcing business rules programmatically. Without them, users could skip critical steps (warehouse forgets to inspect, finance refunds draft returns). Guard Clauses are the core value of State Machines.
How do I prevent users from manually editing the state field?
Add readonly="1" to the state field in XML view. For CSV import backdoors, override create() and write() methods to force vals['state'] = 'draft' on create and block direct state changes in write (check for 'state' in vals). Use context flags to allow state changes only from transition methods.
What does the statusbar widget do?
The statusbar widget (widget="statusbar") renders a visual pipeline showing all states. statusbar_visible controls which states display. State-dependent buttons (states="draft") appear/disappear based on current state. class="oe_highlight" highlights the "next logical step" button in purple, guiding users through the workflow.
Why enable tracking=True on the state field?
tracking=True logs every status change in the Chatter automatically, including who made the change and when. This creates an audit trail for compliance (who approved this return? when was it refunded?). Combined with _inherit = ['mail.thread', 'mail.activity.mixin'], it enables full activity tracking.
Free Workflow Design Workshop
Stop relying on "hope" that your team follows the process. We'll analyze your chaotic manual processes, convert them into strict Odoo State Machines, identify the necessary security groups and validation rules, and draft the Python/XML implementation plan. A good process is invisible. It just works.
