---
url: https://docs.cybrosys.com/cheatsheets/workflows.md
description: >-
  Odoo workflows cheatsheet: statusbar fields, header buttons and conditional
  visibility, with legacy syntax for Odoo 16 and earlier.
---

# Workflows

Examples target Odoo 18 and 19 unless marked. Platform covers Odoo 11 to 20.

## Basic State Management

### State Field Definition

```python
from odoo import models, fields, api

class ModelName(models.Model):
    _name = 'model.name'
    
    state = fields.Selection([
        ('draft', 'Draft'),
        ('confirmed', 'Confirmed'),
        ('done', 'Done'),
        ('cancel', 'Cancelled')
    ], string='Status', default='draft', tracking=True)
```

### State Change Method

```python
def action_confirm(self):
    self.write({'state': 'confirmed'})

def action_done(self):
    self.write({'state': 'done'})

def action_cancel(self):
    self.write({'state': 'cancel'})

def action_draft(self):
    self.write({'state': 'draft'})
```

## Workflow with Conditions

### State Change with Validation

```python
def action_confirm(self):
    if not self.line_ids:
        raise UserError(_('Please add at least one line.'))
    if not self.partner_id:
        raise UserError(_('Please select a partner.'))
    self.write({'state': 'confirmed'})
```

### State Change with Computed Fields

```python
def _compute_can_confirm(self):
    for record in self:
        record.can_confirm = (
            record.state == 'draft' and 
            record.line_ids and 
            record.partner_id
        )

can_confirm = fields.Boolean(
    string='Can Confirm',
    compute='_compute_can_confirm'
)
```

## Workflow with Buttons

### Form View Buttons

**Odoo 17 and later**

```xml
<form>
    <header>
        <button name="action_confirm" 
                string="Confirm" 
                type="object" 
                class="oe_highlight"
                invisible="state != 'draft'"/>
        <button name="action_done" 
                string="Mark as Done" 
                type="object" 
                class="oe_highlight"
                invisible="state != 'confirmed'"/>
        <button name="action_cancel" 
                string="Cancel" 
                type="object" 
                invisible="state in ('done', 'cancel')"/>
        <button name="action_draft" 
                string="Reset to Draft" 
                type="object" 
                invisible="state != 'cancel'"/>
    </header>
    <sheet>
        <field name="state" widget="statusbar"/>
        <!-- Other fields -->
    </sheet>
</form>
```

**Odoo 16 and earlier**

```xml
<form>
    <header>
        <button name="action_confirm" 
                string="Confirm" 
                type="object" 
                class="oe_highlight"
                attrs="{'invisible': [('state', '!=', 'draft')]}"/>
        <button name="action_done" 
                string="Mark as Done" 
                type="object" 
                class="oe_highlight"
                attrs="{'invisible': [('state', '!=', 'confirmed')]}"/>
        <button name="action_cancel" 
                string="Cancel" 
                type="object" 
                attrs="{'invisible': [('state', 'in', ['done', 'cancel'])]}"/>
        <button name="action_draft" 
                string="Reset to Draft" 
                type="object" 
                attrs="{'invisible': [('state', '!=', 'cancel')]}"/>
    </header>
    <sheet>
        <field name="state" widget="statusbar"/>
        <!-- Other fields -->
    </sheet>
</form>
```

From Odoo 17, `attrs` and `states` are removed. Put the condition directly in `invisible`.

## Workflow with Automatic Actions

### Scheduled Actions

```python
def _cron_check_expired(self):
    expired_records = self.search([
        ('state', '=', 'confirmed'),
        ('date_deadline', '<', fields.Date.today())
    ])
    expired_records.write({'state': 'cancel'})
```

### Onchange Methods

```python
@api.onchange('state')
def _onchange_state(self):
    if self.state == 'confirmed':
        self.date_confirmed = fields.Datetime.now()
    elif self.state == 'done':
        self.date_done = fields.Datetime.now()
```

## Workflow with Notifications

### Email Notifications

```python
def action_confirm(self):
    self.write({'state': 'confirmed'})
    template = self.env.ref('module.email_template_confirm')
    template.send_mail(self.id, force_send=True)
```

### Chatter Messages

```python
def action_confirm(self):
    self.write({'state': 'confirmed'})
    self.message_post(
        body=_('Order confirmed by %s') % self.env.user.name,
        message_type='comment'
    )
```

## Workflow with Approvals

### Approval Process

```python
class ModelName(models.Model):
    _name = 'model.name'
    
    state = fields.Selection([
        ('draft', 'Draft'),
        ('to_approve', 'To Approve'),
        ('approved', 'Approved'),
        ('done', 'Done'),
        ('cancel', 'Cancelled')
    ], string='Status', default='draft')
    
    approver_id = fields.Many2one('res.users', string='Approver')
    
    def action_to_approve(self):
        self.write({'state': 'to_approve'})
    
    def action_approve(self):
        if self.env.user != self.approver_id:
            raise UserError(_('Only the approver can approve this record.'))
        self.write({'state': 'approved'})
```

## Workflow with Stages

### Kanban Stages

```python
class ModelName(models.Model):
    _name = 'model.name'
    
    stage_id = fields.Many2one(
        'model.stage',
        string='Stage',
        default=lambda self: self.env['model.stage'].search([], limit=1)
    )
    
    def action_next_stage(self):
        next_stage = self.env['model.stage'].search([
            ('sequence', '>', self.stage_id.sequence)
        ], limit=1)
        if next_stage:
            self.write({'stage_id': next_stage.id})
```

## Workflow with Validation Rules

### State-based Validation

```python
@api.constrains('state', 'amount')
def _check_amount(self):
    for record in self:
        if record.state == 'confirmed' and record.amount <= 0:
            raise ValidationError(_('Amount must be greater than 0 in confirmed state.'))
```

### State-based Computed Fields

```python
@api.depends('state', 'line_ids.price_subtotal')
def _compute_amount_total(self):
    for record in self:
        if record.state == 'draft':
            record.amount_total = sum(record.line_ids.mapped('price_subtotal'))
        else:
            record.amount_total = record.amount_total
```

## Best Practices

### State Names

```python
# Good
state = fields.Selection([
    ('draft', 'Draft'),
    ('confirmed', 'Confirmed'),
    ('done', 'Done'),
    ('cancel', 'Cancelled')
])

# Bad
state = fields.Selection([
    ('new', 'New'),
    ('ok', 'OK'),
    ('finish', 'Finish'),
    ('stop', 'Stop')
])
```

### State Transitions

```python
# Good
def action_confirm(self):
    if self.state != 'draft':
        raise UserError(_('Only draft records can be confirmed.'))
    self.write({'state': 'confirmed'})

# Bad
def action_confirm(self):
    self.write({'state': 'confirmed'})  # No validation
```

### State Documentation

```python
class ModelName(models.Model):
    _name = 'model.name'
    
    state = fields.Selection([
        ('draft', 'Draft'),      # Initial state, can be edited
        ('confirmed', 'Confirmed'),  # Validated, ready for processing
        ('done', 'Done'),        # Processed, read-only
        ('cancel', 'Cancelled')  # Cancelled, can be reset to draft
    ], string='Status', default='draft', tracking=True)
```
