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¶
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:
models/__init__.py:
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¶
# 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¶
- Use
_inheritto extend existing models instead of modifying core files - Add
_descriptionto all models for better documentation - Use sequences for automatic numbering
- Add security rules for all new models
- Write tests for business logic
- Use translations for user-facing strings
- Follow naming conventions (see Coding Standards)