Construindo Módulos Odoo Personalizados: Tutorial para Desenvolvedores
O sistema de módulos do Odoo é uma das estruturas de extensão mais poderosas do mundo ERP. Cada recurso do Odoo – da contabilidade ao inventário e ao CRM – é um módulo. Isso significa que a construção de funcionalidades personalizadas segue exatamente os mesmos padrões usados pelos próprios desenvolvedores do Odoo, dando acesso à estrutura completa sem bifurcar o núcleo.
Este tutorial cobre o ciclo de vida completo de um módulo Odoo 19 personalizado: desde a estruturação da estrutura de diretórios e definição de modelos até a criação de visualizações, proteção de acesso e implantação na produção. Ao final, você terá um módulo funcional que segue as convenções do Odoo 19 Enterprise e está pronto para o mercado.
Principais conclusões
- Cada módulo Odoo é um pacote Python com um descritor
__manifest__.py- Os modelos herdam de
models.Modele mapeiam diretamente para tabelas PostgreSQL- As visualizações são definidas em XML e referenciam os campos do modelo por nome
- A segurança é aplicada por meio de
ir.model.accessCSV e regras de registro- Assistentes (
TransientModel) lidam com interações do usuário em várias etapas- Campos computados e métodos onchange atualizam campos relacionados dinamicamente
- Ações automatizadas e trabalhos agendados executam lógica do lado do servidor em gatilhos
- As dependências do módulo garantem a ordem de carregamento correta e a disponibilidade de recursos
Estrutura e andaimes do módulo
Cada módulo Odoo é um diretório com uma estrutura específica. Use o comando scaffold integrado do Odoo para gerar o padrão:
# From your Odoo addons directory
python odoo-bin scaffold my_module /path/to/addons
Isso gera:
my_module/
├── __init__.py
├── __manifest__.py
├── models/
│ ├── __init__.py
│ └── my_model.py
├── views/
│ └── my_model_views.xml
├── security/
│ ├── ir.model.access.csv
│ └── my_module_security.xml
├── data/
│ └── my_module_data.xml
├── wizard/
│ └── my_wizard.py
├── report/
│ └── my_report.xml
└── static/
└── src/
└── js/
O arquivo de manifesto (__manifest__.py)
{
'name': 'My Custom Module',
'version': '19.0.1.0.0',
'summary': 'Short description for module list',
'description': """
Extended description of what this module does.
Can be multi-line RST text.
""",
'author': 'ECOSIRE Private Limited',
'website': 'https://ecosire.com',
'category': 'Sales/CRM',
'depends': ['sale', 'account', 'stock'],
'data': [
'security/ir.model.access.csv',
'security/my_module_security.xml',
'data/my_module_data.xml',
'views/my_model_views.xml',
'views/menu_views.xml',
'report/my_report.xml',
'wizard/my_wizard_views.xml',
],
'assets': {
'web.assets_backend': [
'my_module/static/src/js/my_widget.js',
'my_module/static/src/css/my_styles.css',
],
},
'license': 'OPL-1',
'installable': True,
'application': False,
'auto_install': False,
'price': 249.0,
'currency': 'USD',
}
Convenção de numeração de versão: {odoo_version}.{major}.{minor}.{patch}. Sempre comece em 19.0.1.0.0 para novos módulos.
Definindo Modelos
Os modelos são o coração de qualquer módulo Odoo. Eles definem a estrutura de dados e a lógica de negócios.
# models/service_request.py
from odoo import api, fields, models
from odoo.exceptions import ValidationError, UserError
class ServiceRequest(models.Model):
_name = 'my.service.request'
_description = 'Service Request'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'date_request desc, name'
_rec_name = 'name'
name = fields.Char(
string='Reference',
required=True,
copy=False,
readonly=True,
default=lambda self: self.env['ir.sequence'].next_by_code('my.service.request')
)
state = fields.Selection([
('draft', 'Draft'),
('submitted', 'Submitted'),
('in_progress', 'In Progress'),
('done', 'Completed'),
('cancelled', 'Cancelled'),
], string='Status', default='draft', tracking=True)
partner_id = fields.Many2one(
'res.partner', string='Customer',
required=True, tracking=True,
domain=[('customer_rank', '>', 0)]
)
user_id = fields.Many2one(
'res.users', string='Assigned To',
default=lambda self: self.env.user
)
date_request = fields.Datetime(
string='Request Date',
default=fields.Datetime.now,
required=True
)
date_deadline = fields.Date(string='Deadline')
description = fields.Html(string='Description')
priority = fields.Selection([
('0', 'Normal'),
('1', 'Low'),
('2', 'High'),
('3', 'Urgent'),
], string='Priority', default='0')
tag_ids = fields.Many2many(
'my.service.tag', string='Tags'
)
line_ids = fields.One2many(
'my.service.request.line', 'request_id',
string='Service Lines'
)
amount_total = fields.Float(
string='Total Amount',
compute='_compute_amount_total',
store=True
)
company_id = fields.Many2one(
'res.company', string='Company',
required=True,
default=lambda self: self.env.company
)
@api.depends('line_ids.subtotal')
def _compute_amount_total(self):
for request in self:
request.amount_total = sum(request.line_ids.mapped('subtotal'))
@api.constrains('date_deadline', 'date_request')
def _check_deadline(self):
for record in self:
if record.date_deadline and record.date_request:
if record.date_deadline < record.date_request.date():
raise ValidationError("Deadline cannot be before the request date.")
@api.onchange('partner_id')
def _onchange_partner_id(self):
if self.partner_id:
self.user_id = self.partner_id.user_id or self.env.user
def action_submit(self):
for record in self:
if not record.line_ids:
raise UserError("Cannot submit a request without service lines.")
record.state = 'submitted'
record.message_post(body="Service request submitted for processing.")
def action_start_progress(self):
self.write({'state': 'in_progress'})
def action_mark_done(self):
self.write({'state': 'done'})
def action_cancel(self):
for record in self:
if record.state == 'done':
raise UserError("Cannot cancel a completed request.")
record.state = 'cancelled'
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', 'New') == 'New':
vals['name'] = self.env['ir.sequence'].next_by_code(
'my.service.request'
) or 'New'
return super().create(vals_list)
Modelo de linha de solicitação de serviço:
class ServiceRequestLine(models.Model):
_name = 'my.service.request.line'
_description = 'Service Request Line'
request_id = fields.Many2one(
'my.service.request', string='Request',
required=True, ondelete='cascade'
)
product_id = fields.Many2one(
'product.product', string='Service',
required=True,
domain=[('type', '=', 'service')]
)
description = fields.Text(string='Description')
quantity = fields.Float(string='Quantity', default=1.0)
price_unit = fields.Float(string='Unit Price')
subtotal = fields.Float(
string='Subtotal',
compute='_compute_subtotal',
store=True
)
@api.depends('quantity', 'price_unit')
def _compute_subtotal(self):
for line in self:
line.subtotal = line.quantity * line.price_unit
@api.onchange('product_id')
def _onchange_product_id(self):
if self.product_id:
self.price_unit = self.product_id.lst_price
self.description = self.product_id.description_sale
Criando visualizações
As visualizações definem como os registros são exibidos na UI. Odoo usa XML para descrever formulários, listas, quadros Kanban e muito mais.
<!-- views/service_request_views.xml -->
<odoo>
<!-- Form View -->
<record id="view_service_request_form" model="ir.ui.view">
<field name="name">my.service.request.form</field>
<field name="model">my.service.request</field>
<field name="arch" type="xml">
<form string="Service Request">
<header>
<button name="action_submit" string="Submit"
type="object" class="oe_highlight"
invisible="state != 'draft'"/>
<button name="action_start_progress" string="Start"
type="object" class="oe_highlight"
invisible="state != 'submitted'"/>
<button name="action_mark_done" string="Mark Done"
type="object" class="oe_highlight"
invisible="state != 'in_progress'"/>
<button name="action_cancel" string="Cancel"
type="object"
invisible="state in ['done', 'cancelled']"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,submitted,in_progress,done"/>
</header>
<sheet>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<group>
<group>
<field name="partner_id"
options="{'no_create': True}"/>
<field name="user_id"/>
<field name="priority" widget="priority"/>
</group>
<group>
<field name="date_request"/>
<field name="date_deadline"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<field name="tag_ids" widget="many2many_tags"/>
<notebook>
<page string="Service Lines">
<field name="line_ids">
<tree editable="bottom">
<field name="product_id"/>
<field name="description"/>
<field name="quantity"/>
<field name="price_unit"/>
<field name="subtotal" readonly="1"/>
</tree>
</field>
<group class="oe_subtotal_footer">
<field name="amount_total"
widget="monetary"
class="oe_subtotal_footer_separator"/>
</group>
</page>
<page string="Description">
<field name="description" widget="html"
placeholder="Detailed description..."/>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- List View -->
<record id="view_service_request_tree" model="ir.ui.view">
<field name="name">my.service.request.list</field>
<field name="model">my.service.request</field>
<field name="arch" type="xml">
<tree string="Service Requests" decoration-danger="state=='cancelled'"
decoration-success="state=='done'">
<field name="name"/>
<field name="partner_id"/>
<field name="user_id" optional="show"/>
<field name="priority" widget="priority"/>
<field name="date_request"/>
<field name="date_deadline" optional="show"/>
<field name="amount_total" sum="Total"/>
<field name="state" widget="badge"
decoration-info="state=='draft'"
decoration-warning="state=='submitted'"
decoration-primary="state=='in_progress'"
decoration-success="state=='done'"
decoration-danger="state=='cancelled'"/>
</tree>
</field>
</record>
<!-- Search View -->
<record id="view_service_request_search" model="ir.ui.view">
<field name="name">my.service.request.search</field>
<field name="model">my.service.request</field>
<field name="arch" type="xml">
<search>
<field name="name" string="Reference"/>
<field name="partner_id"/>
<field name="user_id"/>
<filter string="My Requests" name="my_requests"
domain="[('user_id', '=', uid)]"/>
<filter string="In Progress" name="in_progress"
domain="[('state', '=', 'in_progress')]"/>
<filter string="Urgent" name="urgent"
domain="[('priority', '=', '3')]"/>
<separator/>
<filter string="This Month" name="this_month"
domain="[('date_request', '>=',
(context_today() - relativedelta(day=1)).strftime('%Y-%m-%d'))]"/>
<group expand="0" string="Group By">
<filter string="Customer" name="group_partner"
context="{'group_by': 'partner_id'}"/>
<filter string="Status" name="group_state"
context="{'group_by': 'state'}"/>
<filter string="Assigned To" name="group_user"
context="{'group_by': 'user_id'}"/>
</group>
</search>
</field>
</record>
<!-- Action -->
<record id="action_service_request" model="ir.actions.act_window">
<field name="name">Service Requests</field>
<field name="res_model">my.service.request</field>
<field name="view_mode">list,form,kanban</field>
<field name="search_view_id" ref="view_service_request_search"/>
<field name="context">{'search_default_in_progress': 1}</field>
</record>
</odoo>
Configuração de segurança
A segurança é obrigatória para qualquer módulo de produção.
Lista de controle de acesso (security/ir.model.access.csv):
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_service_request_user,service.request.user,model_my_service_request,base.group_user,1,1,1,0
access_service_request_manager,service.request.manager,model_my_service_request,base.group_system,1,1,1,1
access_service_request_line_user,service.request.line.user,model_my_service_request_line,base.group_user,1,1,1,1
Regras de registro (security/my_module_security.xml):
<odoo>
<!-- Users can only see their own requests unless they're managers -->
<record id="rule_service_request_own" model="ir.rule">
<field name="name">Service Request: Own Records</field>
<field name="model_id" ref="model_my_service_request"/>
<field name="domain_force">
[('user_id', '=', user.id)]
</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
</odoo>
Assistentes (TransientModel)
Os assistentes são formulários temporários para ações guiadas em várias etapas.
# wizard/service_request_wizard.py
from odoo import api, fields, models
class ServiceRequestBulkAssign(models.TransientModel):
_name = 'my.service.request.bulk.assign'
_description = 'Bulk Assign Service Requests'
user_id = fields.Many2one(
'res.users', string='Assign To', required=True
)
request_ids = fields.Many2many(
'my.service.request', string='Requests',
default=lambda self: self.env.context.get('active_ids', [])
)
note = fields.Text(string='Note')
def action_assign(self):
self.request_ids.write({'user_id': self.user_id.id})
if self.note:
for request in self.request_ids:
request.message_post(body=self.note)
return {'type': 'ir.actions.act_window_close'}
Ações e sequências automatizadas
Sequência para numeração automática:
<record id="seq_service_request" model="ir.sequence">
<field name="name">Service Request</field>
<field name="code">my.service.request</field>
<field name="prefix">SRQ/%(year)s/</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
Ação agendada (cron job):
# In the model
def _cron_remind_overdue_requests(self):
overdue = self.search([
('state', 'in', ['submitted', 'in_progress']),
('date_deadline', '<', fields.Date.today()),
])
for request in overdue:
request.activity_schedule(
'mail.mail_activity_data_warning',
summary='Overdue Service Request',
user_id=request.user_id.id
)
<record id="ir_cron_remind_overdue" model="ir.cron">
<field name="name">Remind Overdue Service Requests</field>
<field name="model_id" ref="model_my_service_request"/>
<field name="state">code</field>
<field name="code">model._cron_remind_overdue_requests()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="active">True</field>
</record>
Perguntas frequentes
Qual é a diferença entre models.Model, models.TransientModel e models.AbstractModel?
models.Model cria uma tabela permanente no banco de dados. models.TransientModel cria uma tabela temporária limpa periodicamente (usada para assistentes). models.AbstractModel não cria nenhuma tabela — é um mixin que outros modelos podem herdar para obter métodos e campos sem criar uma tabela separada.
Como estender um modelo Odoo existente sem modificar o código principal?
Use _inherit com o nome do modelo existente e omita _name. Isso adiciona seus campos e métodos ao modelo existente: class SaleOrder(models.Model): _inherit = 'sale.order'. Para criar um novo modelo que copie o comportamento de outro como ponto de partida, use _name (novo nome) e _inherit (modelo de origem).
Como devo lidar com as migrações quando o modelo de dados do meu módulo muda?
Crie um script de migração em my_module/migrations/{version}/pre-migrate.py ou post-migrate.py. Esses scripts são executados automaticamente durante a atualização do módulo. Para renomear colunas, use auxiliares openupgradelib. Sempre teste as migrações em uma cópia do banco de dados de produção antes de aplicá-las à produção.
Posso substituir visualizações Odoo existentes sem modificar os arquivos XML principais?
Sim. Use inherit_id para fazer referência à visualização que deseja estender e, em seguida, use expressões xpath para localizar o elemento e o atributo position (antes, depois, dentro, substituir, atributos) para especificar a modificação. Isso mantém suas alterações isoladas e seguras para atualização.
Como posso tornar um campo específico da empresa em um ambiente multiempresarial?
Use company_dependent=True na definição do campo: my_field = fields.Char(company_dependent=True). Isso armazena um valor separado por empresa, de forma que a Empresa A e a Empresa B possam ter valores diferentes para o mesmo registro. Isso é usado para listas de preços, contas fiscais e outras configurações específicas da empresa.
Qual é a maneira correta de registrar mensagens e depurar durante o desenvolvimento?
Use o módulo logging do Python: import logging; _logger = logging.getLogger(__name__). Use _logger.info(), _logger.warning(), _logger.error() para diferentes níveis de gravidade. Nunca use instruções print() em código de produção. No desenvolvimento, execute o Odoo com --log-level=debug para ver toda a saída de depuração.
Próximas etapas
Construir um módulo Odoo pronto para produção requer conhecimento profundo da estrutura, considerações de desempenho do PostgreSQL, padrões seguros para atualização e testes completos. Os módulos destinados ao mercado Odoo passam por validação adicional quanto à segurança, desempenho e qualidade do código.
A ECOSIRE desenvolve módulos Odoo 19 Enterprise personalizados para requisitos de negócios específicos – desde fluxos de trabalho especializados da indústria até módulos conectores de mercado. Nossa equipe de desenvolvimento segue as diretrizes oficiais de codificação do Odoo, inclui testes unitários abrangentes e entrega módulos com documentação completa.
Comissionar um módulo Odoo personalizado da ECOSIRE →
Compartilhe seus requisitos e nós definiremos o escopo do esforço de desenvolvimento, forneceremos um cronograma e entregaremos um módulo que se integra perfeitamente à instalação do Odoo 19 Enterprise.
Escrito por
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
ECOSIRE
Transforme seu negócio com o Odoo ERP
Implementação, personalização e suporte especializado do Odoo para agilizar suas operações.
Artigos Relacionados
Como adicionar um botão personalizado a uma visualização de formulário Odoo (2026)
Adicione botões de ação personalizados às visualizações de formulário do Odoo 19: método de ação Python, herança de visualização, visibilidade condicional, caixas de diálogo de confirmação. Testado em produção.
Como adicionar um campo personalizado no Odoo sem Studio (2026)
Adicione campos personalizados por meio de módulo personalizado no Odoo 19: herança de modelo, extensão de visualização, campos computados, decisões de loja/não loja. Código primeiro, controlado por versão.
Como adicionar um relatório personalizado no Odoo usando layout externo
Crie um relatório PDF de marca no Odoo 19 usando web.external_layout: modelo QWeb, formato de papel, vinculação de ação. Com logotipo impresso + substituições de rodapé.