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))

Module Upgrade Requirements

When to Upgrade Modules

CRITICAL: After editing ANY of these files, you MUST run module upgrade:

File Type Example Requires Upgrade
XML Views views/*.xml YES
XML Data data/*.xml YES
XML Reports reports/*.xml YES
Model Fields models/*.py (adding/removing fields) YES
Manifest __manifest__.py YES
Security security/*.csv YES
Python Logic Only models/*.py (method changes only) Restart only

How to Upgrade

# Using production_deploy.sh script
./production_deploy.sh update-module MODULE_NAME

# Or directly with docker
docker-compose exec odoo odoo \
    --db_host=db --db_user=odoo --db_password='PASSWORD' \
    -d DATABASE_NAME -u MODULE_NAME --stop-after-init

View Caching Issue

Odoo caches views in the database (ir_ui_view table). If you rename or remove fields:

  1. The old field references remain in cached database views
  2. Module upgrade will fail with "Field X does not exist"
  3. You must update the cached views before upgrade:
-- Find views with the old field
SELECT id, name, model FROM ir_ui_view WHERE arch_db LIKE '%old_field%';

-- Update views to use new field name
UPDATE ir_ui_view SET arch_db = REPLACE(arch_db, 'old_field', 'new_field')
WHERE id IN (1234, 5678);

Manifest Load Order

In __manifest__.py, file order matters:

'data': [
    # 1. Security first
    'security/security.xml',
    'security/ir.model.access.csv',

    # 2. Data files
    'data/master_data.xml',

    # 3. Actions BEFORE menus (menus reference actions)
    'views/action_views.xml',

    # 4. Menus AFTER actions
    'views/menu_views.xml',

    # 5. Other views
    'views/model_views.xml',

    # 6. Reports
    'reports/report_templates.xml',

    # 7. Wizards
    'wizards/wizard_views.xml',
],

Inheriting from bi_product_dimension

When working with sale/purchase order lines, use fields from bi_product_dimension:

Field Mapping (SO → PO)

sale.order.line purchase.order.line Description
width width Width in mm
height height Height in mm
item_location_id item_location_id Location (Many2one)
motorized string_side Control type*
mount - In/Out mount
supply_color_id supply_color_id Supply color

*Note: The motorized field on SO line maps to string_side on PO line via procurement.

Using item_location_id

Always use item_location_id instead of custom location fields:

def _get_or_create_location(self, location_name):
    """Get or create item.location record by name."""
    Location = self.env['item.location']
    location = Location.search([('name', '=', location_name)], limit=1)
    if not location:
        location = Location.create({'name': location_name})
    return location

# Usage in wizard
location = self._get_or_create_location(window.location)
vals = {
    'order_id': self.order_id.id,
    'item_location_id': location.id,  # This flows to PO automatically
    ...
}

Selection Field Values

Match values exactly from bi_product_dimension:

# motorized field values (sale.order.line)
# string_side field values (purchase.order.line)
[
    ('L', 'L'),
    ('R', 'R'),
    ('Motorzied', 'Motorzied'),  # Note: typo in original
    ('One Cord', 'One Cord'),
    ('Cordless Roll', 'Cordless Roll'),
    # ... etc
]

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)
  8. Always upgrade modules after XML or model changes
  9. Check bi_product_dimension before creating new dimension/location fields
  10. Test field flow from SO → PO when modifying order line fields