Skip to content

Module Development

Guide to creating and extending Odoo 15 modules.

Module Structure

my_module/
├── __init__.py
├── __manifest__.py
├── models/
│   ├── __init__.py
│   └── my_model.py
├── views/
│   └── my_model_views.xml
├── security/
│   ├── ir.model.access.csv
│   └── security.xml
├── data/
│   └── data.xml
├── static/
│   └── description/
│       └── icon.png
└── README.md

Creating a New Module

Step 1: Create Module Directory

mkdir -p extra-addons/odoo/my_module/{models,views,security,data}

Step 2: Create __manifest__.py

# -*- coding: utf-8 -*-
{
    'name': 'My Module',
    'version': '15.0.1.0.0',
    'summary': 'Short description of the module',
    'description': """
        Long description of the module.
        Can span multiple lines.
    """,
    'category': 'Sales',
    'author': 'Your Company',
    'website': 'https://yourcompany.com',
    'license': 'LGPL-3',
    'depends': ['base', 'sale'],
    'data': [
        'security/ir.model.access.csv',
        'views/my_model_views.xml',
        'data/data.xml',
    ],
    'demo': [],
    'installable': True,
    'auto_install': False,
    'application': False,
}

Step 3: Create __init__.py Files

Root __init__.py:

# -*- coding: utf-8 -*-
from . import models

models/__init__.py:

# -*- coding: utf-8 -*-
from . import my_model

Step 4: Create Model

models/my_model.py:

# -*- coding: utf-8 -*-
from odoo import api, fields, models


class MyModel(models.Model):
    _name = 'my.model'
    _description = 'My Model'

    name = fields.Char(string='Name', required=True)
    description = fields.Text(string='Description')
    active = fields.Boolean(default=True)
    partner_id = fields.Many2one('res.partner', string='Partner')
    state = fields.Selection([
        ('draft', 'Draft'),
        ('confirmed', 'Confirmed'),
        ('done', 'Done'),
    ], string='Status', default='draft')

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

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

Step 5: Create Security

security/ir.model.access.csv:

id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_my_model_user,my.model.user,model_my_model,base.group_user,1,1,1,0
access_my_model_manager,my.model.manager,model_my_model,base.group_system,1,1,1,1

Step 6: Create Views

views/my_model_views.xml:

<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <!-- Tree View -->
    <record id="my_model_view_tree" model="ir.ui.view">
        <field name="name">my.model.tree</field>
        <field name="model">my.model</field>
        <field name="arch" type="xml">
            <tree>
                <field name="name"/>
                <field name="partner_id"/>
                <field name="state"/>
            </tree>
        </field>
    </record>

    <!-- Form View -->
    <record id="my_model_view_form" model="ir.ui.view">
        <field name="name">my.model.form</field>
        <field name="model">my.model</field>
        <field name="arch" type="xml">
            <form>
                <header>
                    <button name="action_confirm" string="Confirm"
                            type="object" states="draft" class="btn-primary"/>
                    <button name="action_done" string="Done"
                            type="object" states="confirmed" class="btn-primary"/>
                    <field name="state" widget="statusbar"/>
                </header>
                <sheet>
                    <group>
                        <group>
                            <field name="name"/>
                            <field name="partner_id"/>
                        </group>
                        <group>
                            <field name="active"/>
                        </group>
                    </group>
                    <notebook>
                        <page string="Description">
                            <field name="description"/>
                        </page>
                    </notebook>
                </sheet>
            </form>
        </field>
    </record>

    <!-- Search View -->
    <record id="my_model_view_search" model="ir.ui.view">
        <field name="name">my.model.search</field>
        <field name="model">my.model</field>
        <field name="arch" type="xml">
            <search>
                <field name="name"/>
                <field name="partner_id"/>
                <filter name="draft" string="Draft" domain="[('state','=','draft')]"/>
                <filter name="confirmed" string="Confirmed" domain="[('state','=','confirmed')]"/>
                <group expand="0" string="Group By">
                    <filter name="group_state" string="Status" context="{'group_by': 'state'}"/>
                    <filter name="group_partner" string="Partner" context="{'group_by': 'partner_id'}"/>
                </group>
            </search>
        </field>
    </record>

    <!-- Action -->
    <record id="my_model_action" model="ir.actions.act_window">
        <field name="name">My Models</field>
        <field name="res_model">my.model</field>
        <field name="view_mode">tree,form</field>
        <field name="help" type="html">
            <p class="o_view_nocontent_smiling_face">
                Create your first record
            </p>
        </field>
    </record>

    <!-- Menu -->
    <menuitem id="my_model_menu_root" name="My Module" sequence="100"/>
    <menuitem id="my_model_menu" name="My Models"
              parent="my_model_menu_root" action="my_model_action"/>
</odoo>

Extending Existing Models

Inherit and Add Fields

class ResPartner(models.Model):
    _inherit = 'res.partner'

    loyalty_points = fields.Integer(string='Loyalty Points', default=0)
    membership_level = fields.Selection([
        ('bronze', 'Bronze'),
        ('silver', 'Silver'),
        ('gold', 'Gold'),
    ], string='Membership Level', default='bronze')

Override Methods

class SaleOrder(models.Model):
    _inherit = 'sale.order'

    @api.model
    def create(self, vals):
        # Add custom logic before create
        if vals.get('partner_id'):
            partner = self.env['res.partner'].browse(vals['partner_id'])
            if partner.loyalty_points > 1000:
                vals['discount'] = 10
        return super().create(vals)

Extend Views

<record id="view_partner_form_loyalty" model="ir.ui.view">
    <field name="name">res.partner.form.loyalty</field>
    <field name="model">res.partner</field>
    <field name="inherit_id" ref="base.view_partner_form"/>
    <field name="arch" type="xml">
        <xpath expr="//field[@name='phone']" position="after">
            <field name="loyalty_points"/>
            <field name="membership_level"/>
        </xpath>
    </field>
</record>

Common Field Types

Type Usage Example
Char Short text name = fields.Char(size=100)
Text Long text description = fields.Text()
Integer Whole numbers quantity = fields.Integer()
Float Decimals price = fields.Float(digits=(12,2))
Boolean True/False active = fields.Boolean(default=True)
Date Date only date = fields.Date()
Datetime Date and time timestamp = fields.Datetime()
Selection Dropdown state = fields.Selection([...])
Many2one Single relation partner_id = fields.Many2one('res.partner')
One2many Multiple records line_ids = fields.One2many('sale.order.line', 'order_id')
Many2many Many-to-many tag_ids = fields.Many2many('res.partner.category')
Binary File/Image image = fields.Binary()
Html Rich text content = fields.Html()

Computed Fields

total_amount = fields.Float(compute='_compute_total', store=True)

@api.depends('line_ids.price_subtotal')
def _compute_total(self):
    for record in self:
        record.total_amount = sum(record.line_ids.mapped('price_subtotal'))

Onchange Methods

@api.onchange('partner_id')
def _onchange_partner_id(self):
    if self.partner_id:
        self.payment_term_id = self.partner_id.property_payment_term_id

Install & Update Module

# Install new module
docker compose exec odoo odoo -i my_module --stop-after-init -d odoo_test

# Update existing module
docker compose exec odoo odoo -u my_module --stop-after-init -d odoo_test

# Update module list (after adding new module)
docker compose exec odoo odoo -u base --stop-after-init -d odoo_test

Debugging

Enable Developer Mode

Add ?debug=1 to URL or: Settings → Developer Tools → Activate

Odoo Shell

docker compose exec odoo odoo shell -d odoo_test
# In shell
>>> partner = env['res.partner'].search([('name', 'ilike', 'test')], limit=1)
>>> partner.name
'Test Company'
>>> partner.write({'phone': '555-1234'})
>>> env.cr.commit()

Logging

import logging
_logger = logging.getLogger(__name__)

_logger.info("Processing order: %s", self.name)
_logger.debug("Details: %s", vals)
_logger.error("Failed: %s", str(e))

Best Practices

  1. Use _inherit to extend existing models instead of modifying core files
  2. Add _description to all models for better documentation
  3. Use sequences for automatic numbering
  4. Add security rules for all new models
  5. Write tests for business logic
  6. Use translations for user-facing strings
  7. Follow naming conventions (see Coding Standards)