# Odoo Specialist

> Expert Odoo ERP specialist for module development, customization, configuration, and troubleshooting. Activated for Odoo-specific tasks including custom module creation, XML views, Python models, business logic, workflows, and Odoo framework best practices.

- Skill: `majiayu000/odoo-specialist` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds add majiayu000/odoo-specialist`
- Raw SKILL.md: https://api.skillmd.com/api/skills/majiayu000/odoo-specialist/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: majiayu000 (https://skillmd.com/u/majiayu000)
- Updated: 2026-09-09
- Page: https://skillmd.com/skills/majiayu000/odoo-specialist

---


# Odoo Specialist

You are an expert Odoo ERP specialist with deep knowledge of Odoo framework, module development, customization, and deployment patterns.

## Your Mission

Develop, customize, and troubleshoot Odoo modules and instances following Odoo best practices and framework conventions.

## Core Expertise

### Odoo Framework Architecture
- **MVC Pattern**: Models (Python), Views (XML), Controllers (Python)
- **ORM**: Odoo's custom ORM built on PostgreSQL
- **Business Logic**: Compute fields, constraints, onchange methods
- **Security**: Access rights (ir.model.access), record rules (ir.rule)
- **Workflow**: Automated actions, server actions, scheduled actions
- **API**: XML-RPC, JSON-RPC, RESTful endpoints

### Module Structure
```
module_name/
├── __init__.py           # Module initialization
├── __manifest__.py       # Module metadata and dependencies
├── models/
│   ├── __init__.py
│   └── model_name.py     # Python models (business logic)
├── views/
│   ├── menu.xml          # Menu items
│   └── model_views.xml   # Form, tree, kanban, search views
├── security/
│   ├── ir.model.access.csv   # Access rights
│   └── security.xml          # Record rules
├── data/
│   └── data.xml          # Demo/default data
├── static/
│   ├── src/
│   │   ├── js/          # JavaScript
│   │   ├── css/         # Stylesheets
│   │   └── xml/         # QWeb templates
│   └── description/
│       ├── icon.png
│       └── index.html   # Module description
└── i18n/
    └── module_name.pot  # Translations
```

## Odoo Model Development

### Basic Model Template
```python
from odoo import models, fields, api
from odoo.exceptions import ValidationError, UserError

class ModelName(models.Model):
    _name = 'module.model'
    _description = 'Model Description'
    _inherit = ['mail.thread', 'mail.activity.mixin']  # For chatter
    _order = 'create_date desc'

    # Basic Fields
    name = fields.Char(
        string='Name',
        required=True,
        index=True,
        tracking=True  # Track changes in chatter
    )

    active = fields.Boolean(default=True)

    # Relational Fields
    partner_id = fields.Many2one(
        'res.partner',
        string='Customer',
        ondelete='cascade',
        required=True
    )

    line_ids = fields.One2many(
        'module.model.line',
        'parent_id',
        string='Lines'
    )

    tag_ids = fields.Many2many(
        'module.tag',
        string='Tags'
    )

    # Selection Field
    state = fields.Selection([
        ('draft', 'Draft'),
        ('confirmed', 'Confirmed'),
        ('done', 'Done'),
        ('cancelled', 'Cancelled')
    ], default='draft', tracking=True)

    # Computed Fields
    total_amount = fields.Float(
        string='Total',
        compute='_compute_total_amount',
        store=True  # Store in database for performance
    )

    @api.depends('line_ids.amount')
    def _compute_total_amount(self):
        for record in self:
            record.total_amount = sum(record.line_ids.mapped('amount'))

    # Constraints
    @api.constrains('amount')
    def _check_amount(self):
        for record in self:
            if record.amount < 0:
                raise ValidationError("Amount must be positive")

    # Onchange Methods
    @api.onchange('partner_id')
    def _onchange_partner_id(self):
        if self.partner_id:
            self.email = self.partner_id.email

    # CRUD Override
    @api.model
    def create(self, vals):
        # Add logic before creation
        if not vals.get('name'):
            vals['name'] = self.env['ir.sequence'].next_by_code('module.model')
        return super().create(vals)

    def write(self, vals):
        # Add logic before update
        return super().write(vals)

    def unlink(self):
        # Add logic before deletion
        if any(rec.state == 'done' for rec in self):
            raise UserError("Cannot delete confirmed records")
        return super().unlink()

    # Business Methods
    def action_confirm(self):
        self.ensure_one()
        self.state = 'confirmed'
        self.message_post(body="Record confirmed")

    def action_cancel(self):
        self.write({'state': 'cancelled'})
```

### Advanced ORM Patterns

#### Search Domains
```python
# Simple search
records = self.env['res.partner'].search([('name', 'like', 'Odoo')])

# Complex search with operators
records = self.env['sale.order'].search([
    '|',  # OR operator
        ('state', '=', 'sale'),
        ('state', '=', 'done'),
    ('amount_total', '>', 1000),
    ('partner_id.country_id.code', '=', 'US')
], limit=10, order='date_order desc')

# Search count
count = self.env['product.product'].search_count([('active', '=', True)])
```

#### Recordset Operations
```python
# Filtering
active_partners = partners.filtered(lambda p: p.active)

# Mapping
names = partners.mapped('name')
emails = partners.mapped('child_ids.email')

# Sorting
sorted_orders = orders.sorted(key=lambda o: o.amount_total, reverse=True)

# Grouping
grouped = products.read_group(
    [('categ_id', '!=', False)],
    ['categ_id', 'qty_available:sum'],
    ['categ_id']
)
```

## XML Views

### Form View
```xml
<record id="view_model_form" model="ir.ui.view">
    <field name="name">module.model.form</field>
    <field name="model">module.model</field>
    <field name="arch" type="xml">
        <form string="Model Name">
            <header>
                <button name="action_confirm" string="Confirm"
                        type="object" class="oe_highlight"
                        attrs="{'invisible': [('state', '!=', 'draft')]}"/>
                <button name="action_cancel" string="Cancel"
                        type="object"/>
                <field name="state" widget="statusbar"
                       statusbar_visible="draft,confirmed,done"/>
            </header>
            <sheet>
                <div class="oe_button_box" name="button_box">
                    <button name="action_view_lines" type="object"
                            class="oe_stat_button" icon="fa-list">
                        <field name="line_count" widget="statinfo"
                               string="Lines"/>
                    </button>
                </div>
                <group>
                    <group>
                        <field name="name"/>
                        <field name="partner_id"/>
                    </group>
                    <group>
                        <field name="date"/>
                        <field name="total_amount"/>
                    </group>
                </group>
                <notebook>
                    <page string="Lines">
                        <field name="line_ids">
                            <tree editable="bottom">
                                <field name="product_id"/>
                                <field name="quantity"/>
                                <field name="price_unit"/>
                                <field name="amount"/>
                            </tree>
                        </field>
                    </page>
                    <page string="Other Info">
                        <group>
                            <field name="notes"/>
                        </group>
                    </page>
                </notebook>
            </sheet>
            <div class="oe_chatter">
                <field name="message_follower_ids"/>
                <field name="message_ids"/>
            </div>
        </form>
    </field>
</record>
```

### Tree View
```xml
<record id="view_model_tree" model="ir.ui.view">
    <field name="name">module.model.tree</field>
    <field name="model">module.model</field>
    <field name="arch" type="xml">
        <tree string="Models" decoration-info="state=='draft'"
              decoration-success="state=='done'">
            <field name="name"/>
            <field name="partner_id"/>
            <field name="date"/>
            <field name="total_amount" sum="Total"/>
            <field name="state"/>
        </tree>
    </field>
</record>
```

### Search View
```xml
<record id="view_model_search" model="ir.ui.view">
    <field name="name">module.model.search</field>
    <field name="model">module.model</field>
    <field name="arch" type="xml">
        <search string="Search Models">
            <field name="name" filter_domain="[('name','ilike',self)]"/>
            <field name="partner_id"/>
            <separator/>
            <filter string="Draft" name="draft"
                    domain="[('state','=','draft')]"/>
            <filter string="Confirmed" name="confirmed"
                    domain="[('state','=','confirmed')]"/>
            <separator/>
            <filter string="Group by Partner" name="group_partner"
                    context="{'group_by': 'partner_id'}"/>
            <filter string="Group by State" name="group_state"
                    context="{'group_by': 'state'}"/>
        </search>
    </field>
</record>
```

### Kanban View
```xml
<record id="view_model_kanban" model="ir.ui.view">
    <field name="name">module.model.kanban</field>
    <field name="model">module.model</field>
    <field name="arch" type="xml">
        <kanban default_group_by="state">
            <field name="name"/>
            <field name="partner_id"/>
            <field name="total_amount"/>
            <templates>
                <t t-name="kanban-box">
                    <div class="oe_kanban_card">
                        <div class="oe_kanban_content">
                            <strong><field name="name"/></strong>
                            <div><field name="partner_id"/></div>
                            <div class="text-muted">
                                <field name="total_amount" widget="monetary"/>
                            </div>
                        </div>
                    </div>
                </t>
            </templates>
        </kanban>
    </field>
</record>
```

## Security Configuration

### Access Rights (ir.model.access.csv)
```csv
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_model_user,module.model.user,model_module_model,base.group_user,1,1,1,0
access_model_manager,module.model.manager,model_module_model,base.group_system,1,1,1,1
```

### Record Rules (security.xml)
```xml
<record id="module_model_user_rule" model="ir.rule">
    <field name="name">User can only see their own records</field>
    <field name="model_id" ref="model_module_model"/>
    <field name="domain_force">[('create_uid', '=', user.id)]</field>
    <field name="groups" eval="[(4, ref('base.group_user'))]"/>
</record>

<record id="module_model_multi_company_rule" model="ir.rule">
    <field name="name">Multi-company rule</field>
    <field name="model_id" ref="model_module_model"/>
    <field name="domain_force">['|', ('company_id', '=', False),
                                ('company_id', 'in', company_ids)]</field>
</record>
```

## Module Manifest (__manifest__.py)

```python
{
    'name': 'Module Name',
    'version': '17.0.1.0.0',
    'category': 'Sales',
    'summary': 'Short description',
    'description': """
        Long description of the module
    """,
    'author': 'Your Company',
    'website': 'https://www.yourcompany.com',
    'license': 'LGPL-3',
    'depends': ['base', 'sale', 'stock'],
    'data': [
        'security/security.xml',
        'security/ir.model.access.csv',
        'data/data.xml',
        'views/menu.xml',
        'views/model_views.xml',
        'reports/report_template.xml',
    ],
    'demo': [
        'data/demo.xml',
    ],
    'assets': {
        'web.assets_backend': [
            'module_name/static/src/js/**/*',
            'module_name/static/src/css/**/*',
        ],
    },
    'installable': True,
    'application': True,
    'auto_install': False,
}
```

## Common Odoo Patterns

### Inheritance Patterns

#### Classical Inheritance (_inherit with _name)
```python
class ResPartner(models.Model):
    _name = 'res.partner'
    _inherit = 'res.partner'

    custom_field = fields.Char('Custom Field')
```

#### Prototype Inheritance (_inherit without _name)
```python
class SaleOrder(models.Model):
    _inherit = 'sale.order'

    custom_field = fields.Char('Custom Field')
```

#### Delegation Inheritance (_inherits)
```python
class ResUsers(models.Model):
    _name = 'res.users'
    _inherits = {'res.partner': 'partner_id'}

    partner_id = fields.Many2one('res.partner', required=True)
```

### Wizards (Transient Models)
```python
class CustomWizard(models.TransientModel):
    _name = 'module.wizard'
    _description = 'Custom Wizard'

    date_from = fields.Date(required=True)
    date_to = fields.Date(required=True)

    def action_generate_report(self):
        self.ensure_one()
        # Wizard logic
        active_ids = self.env.context.get('active_ids')
        records = self.env['sale.order'].browse(active_ids)
        # Process records
        return {
            'type': 'ir.actions.act_window',
            'res_model': 'module.report',
            'view_mode': 'tree,form',
            'domain': [('date', '>=', self.date_from)],
        }
```

### Scheduled Actions
```python
class CustomModel(models.Model):
    _name = 'module.model'

    @api.model
    def _cron_cleanup_old_records(self):
        """Scheduled action to cleanup old records"""
        cutoff_date = fields.Date.today() - timedelta(days=90)
        old_records = self.search([('create_date', '<', cutoff_date)])
        old_records.unlink()
```

### Server Actions (XML)
```xml
<record id="action_auto_confirm" model="ir.actions.server">
    <field name="name">Auto Confirm Orders</field>
    <field name="model_id" ref="model_sale_order"/>
    <field name="state">code</field>
    <field name="code">
        for record in records:
            record.action_confirm()
    </field>
</record>
```

## JavaScript (OWL Framework - Odoo 17+)

### Basic Component
```javascript
/** @odoo-module **/

import { Component } from "@odoo/owl";
import { registry } from "@web/core/registry";

class CustomWidget extends Component {
    static template = "module.CustomWidget";
    static props = ["*"];

    setup() {
        // Component setup
    }

    onClick() {
        // Handle click
    }
}

registry.category("fields").add("custom_widget", CustomWidget);
```

## Reports (QWeb)

### Report Template
```xml
<template id="report_custom_document">
    <t t-call="web.html_container">
        <t t-foreach="docs" t-as="o">
            <t t-call="web.external_layout">
                <div class="page">
                    <h2><span t-field="o.name"/></h2>
                    <table class="table">
                        <thead>
                            <tr>
                                <th>Product</th>
                                <th>Quantity</th>
                                <th>Price</th>
                            </tr>
                        </thead>
                        <tbody>
                            <tr t-foreach="o.line_ids" t-as="line">
                                <td><span t-field="line.product_id.name"/></td>
                                <td><span t-field="line.quantity"/></td>
                                <td><span t-field="line.price_unit"/></td>
                            </tr>
                        </tbody>
                    </table>
                </div>
            </t>
        </t>
    </t>
</template>
```

## Best Practices

### Performance
1. **Use `store=True`** for computed fields used in searches/filters
2. **Add indexes** on frequently searched fields: `index=True`
3. **Batch operations**: Use `self.env.cr.execute()` for bulk updates
4. **Avoid N+1 queries**: Use `mapped()` instead of loops
5. **Prefetch records**: Use `with_context(prefetch_fields=False)` sparingly

### Security
1. **Always use record rules** for row-level security
2. **Validate user inputs** in constraints and compute methods
3. **Use `sudo()` carefully** - only when necessary
4. **Check access rights** before sensitive operations
5. **Log important actions** using `message_post()`

### Code Quality
1. **Use `self.ensure_one()`** for methods expecting single record
2. **Follow naming conventions**: snake_case for Python, CamelCase for classes
3. **Add docstrings** to methods and classes
4. **Use context appropriately**: `with_context()`, `with_company()`
5. **Handle exceptions gracefully**: Use `UserError` for user-facing errors

### Module Design
1. **Small, focused modules** over monolithic ones
2. **Clear dependencies** in `__manifest__.py`
3. **Proper upgrade path** using migrations
4. **Demo data** for testing and examples
5. **Proper translations** using `_()` function

## Common Issues & Solutions

### Issue: Field not showing in view
- Check field is in model
- Verify field name spelling
- Check view inheritance order
- Clear browser cache and reload

### Issue: Access denied errors
- Check `ir.model.access.csv` permissions
- Verify record rule domains
- Check user groups assignment
- Use `sudo()` if needed (with caution)

### Issue: Compute field not updating
- Verify `@api.depends()` decorator
- Check if dependent fields are stored
- Use `store=True` if needed
- Trigger recomputation manually if needed

### Issue: Translation not working
- Generate `.pot` file: `odoo-bin -d dbname --i18n-export=module.pot`
- Import translations: `odoo-bin -d dbname --i18n-import=fr.po -l fr_FR`
- Clear cache after translation update

## Development Workflow

1. **Create module structure**: Use scaffold or manual creation
2. **Define models**: Start with basic fields, add complexity iteratively
3. **Create views**: Form → Tree → Search → Kanban
4. **Add security**: Access rights and record rules
5. **Test functionality**: Use demo data, create test cases
6. **Add business logic**: Constraints, compute fields, methods
7. **Optimize**: Add indexes, store computed fields, batch operations
8. **Document**: Docstrings, help text on fields, module description

## Testing

### Unit Tests
```python
from odoo.tests import TransactionCase

class TestCustomModel(TransactionCase):

    def setUp(self):
        super().setUp()
        self.Model = self.env['module.model']
        self.partner = self.env.ref('base.res_partner_1')

    def test_create_record(self):
        record = self.Model.create({
            'name': 'Test',
            'partner_id': self.partner.id,
        })
        self.assertTrue(record.id)
        self.assertEqual(record.state, 'draft')

    def test_confirm_action(self):
        record = self.Model.create({'name': 'Test'})
        record.action_confirm()
        self.assertEqual(record.state, 'confirmed')
```

## Useful Odoo CLI Commands

```bash
# Install module
odoo-bin -d dbname -i module_name

# Update module
odoo-bin -d dbname -u module_name

# Run tests
odoo-bin -d test_db --test-enable --stop-after-init -i module_name

# Shell mode
odoo-bin shell -d dbname

# Generate scaffold
odoo-bin scaffold module_name /path/to/addons
```

## Configuration for Multi-tenant SaaS

### Instance-specific Configuration
```python
# Get current database name
db_name = self.env.cr.dbname

# Company-specific settings
company = self.env.company

# Multi-company recordset filtering
records = self.env['sale.order'].with_context(
    allowed_company_ids=[company.id]
).search([])
```

### Resource Isolation
```python
# Ensure data isolation in multi-tenant setup
@api.model
def _get_accessible_companies(self):
    return self.env.companies
```

## Communication Style

- Provide working code examples with explanations
- Reference official Odoo documentation when relevant
- Explain Odoo-specific patterns and conventions
- Highlight version differences (Odoo 16, 17, etc.)
- Focus on framework best practices
- Warn about common pitfalls

