Building Custom Odoo Modules: Developer Tutorial

Step-by-step tutorial for building custom Odoo 19 modules. Covers module structure, models, views, security, wizards, and best practices for production-ready code.

E
ECOSIRE Research and Development Team
|19 de marzo de 20269 min de lectura1.9k Palabras|

Creación de módulos Odoo personalizados: tutorial para desarrolladores

El sistema de módulos de Odoo es uno de los marcos de extensión más poderosos del mundo ERP. Cada característica de Odoo, desde contabilidad hasta inventario y CRM, es un módulo. Esto significa que la creación de funcionalidades personalizadas sigue exactamente los mismos patrones utilizados por los propios desarrolladores de Odoo, lo que le brinda acceso al marco completo sin bifurcar el núcleo.

Este tutorial cubre el ciclo de vida completo de un módulo personalizado de Odoo 19: desde el andamiaje de la estructura del directorio y la definición de modelos, hasta la creación de vistas, la seguridad del acceso y la implementación en producción. Al final, tendrá un módulo funcional que sigue las convenciones de Odoo 19 Enterprise y estará listo para el mercado.

Conclusiones clave

  • Cada módulo de Odoo es un paquete de Python con un descriptor __manifest__.py
  • Los modelos heredan de models.Model y se asignan directamente a tablas de PostgreSQL
  • Las vistas se definen en XML y en los campos del modelo de referencia por nombre.
  • La seguridad se aplica a través de ir.model.access CSV y reglas de registro
  • Los asistentes (TransientModel) manejan interacciones de usuario de varios pasos
  • Los campos calculados y los métodos de cambio actualizan los campos relacionados dinámicamente
  • Las acciones automatizadas y los trabajos programados ejecutan la lógica del lado del servidor en los activadores.
  • Las dependencias de los módulos garantizan el orden de carga correcto y la disponibilidad de funciones.

Estructura y andamiaje del módulo

Cada módulo de Odoo es un directorio con una estructura específica. Utilice el comando scaffold integrado de Odoo para generar el texto estándar:

# From your Odoo addons directory
python odoo-bin scaffold my_module /path/to/addons

Esto genera:

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/

El archivo de manifiesto (__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',
}

Convención de numeración de versiones: {odoo_version}.{major}.{minor}.{patch}. Comience siempre en 19.0.1.0.0 para módulos nuevos.


Definición de modelos

Los modelos son el corazón de cualquier módulo de Odoo. Definen la estructura de datos y la lógica empresarial.

# 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 Línea de Solicitud de Servicio:

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

Creando vistas

Las vistas definen cómo se muestran los registros en la interfaz de usuario. Odoo usa XML para describir formularios, listas, tableros kanban y más.

<!-- 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', '&gt;=',
                                  (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>

Configuración de seguridad

La seguridad es obligatoria para cualquier módulo de producción.

Lista de control de acceso (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

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

Asistentes (Modelo transitorio)

Los asistentes son formas temporales para acciones guiadas de varios pasos.

# 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'}

Acciones y secuencias automatizadas

Secuencia de numeración 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>

Acción programada (trabajo cron):

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

Preguntas frecuentes

¿Cuál es la diferencia entre modelos.Model, modelos.TransientModel y modelos.AbstractModel?

models.Model crea una tabla permanente en la base de datos. models.TransientModel crea una tabla temporal que se borra periódicamente (utilizada para asistentes). models.AbstractModel no crea ninguna tabla; es un mixin que otros modelos pueden heredar para obtener métodos y campos sin crear una tabla separada.

¿Cómo extiendo un modelo de Odoo existente sin modificar el código central?

Utilice _inherit con el nombre del modelo existente y omita _name. Esto agrega sus campos y métodos al modelo existente: class SaleOrder(models.Model): _inherit = 'sale.order'. Para crear un nuevo modelo que copie el comportamiento de otra persona como punto de partida, utilice _name (nuevo nombre) y _inherit (modelo fuente).

¿Cómo debo manejar las migraciones cuando cambia el modelo de datos de mi módulo?

Cree un script de migración en my_module/migrations/{version}/pre-migrate.py o post-migrate.py. Estos scripts se ejecutan automáticamente durante la actualización del módulo. Para cambiar el nombre de las columnas, utilice los ayudantes openupgradelib. Pruebe siempre las migraciones en una copia de la base de datos de producción antes de aplicar a producción.

¿Puedo anular las vistas existentes de Odoo sin modificar los archivos XML principales?

Sí. Utilice inherit_id para hacer referencia a la vista que desea ampliar, luego utilice expresiones xpath para localizar el elemento y el atributo position (antes, después, dentro, reemplazar, atributos) para especificar la modificación. Esto mantiene sus cambios aislados y seguros para la actualización.

¿Cómo puedo hacer que un campo sea específico de una empresa en un entorno de varias empresas?

Utilice company_dependent=True en la definición de campo: my_field = fields.Char(company_dependent=True). Esto almacena un valor separado por empresa, por lo que la Empresa A y la Empresa B pueden tener valores diferentes para el mismo registro. Esto se utiliza para listas de precios, cuentas de impuestos y otras configuraciones específicas de la empresa.

¿Cuál es la forma correcta de registrar mensajes y depurar durante el desarrollo?

Utilice el módulo logging de Python: import logging; _logger = logging.getLogger(__name__). Utilice _logger.info(), _logger.warning(), _logger.error() para diferentes niveles de gravedad. Nunca utilice declaraciones print() en código de producción. En desarrollo, ejecute Odoo con --log-level=debug para ver todos los resultados de depuración.


Próximos pasos

La creación de un módulo Odoo listo para producción requiere un conocimiento profundo del marco, consideraciones de rendimiento de PostgreSQL, patrones de actualización segura y pruebas exhaustivas. Los módulos destinados al mercado de Odoo se someten a una validación adicional de seguridad, rendimiento y calidad del código.

ECOSIRE desarrolla módulos Odoo 19 Enterprise personalizados para requisitos comerciales específicos, desde flujos de trabajo industriales especializados hasta módulos de conectores de mercado. Nuestro equipo de desarrollo sigue las pautas de codificación oficiales de Odoo, incluye pruebas unitarias integrales y entrega módulos con documentación completa.

Encargar un módulo Odoo personalizado de ECOSIRE →

Comparta sus requisitos y evaluaremos el esfuerzo de desarrollo, le proporcionaremos un cronograma y le entregaremos un módulo que se integra perfectamente con su instalación de Odoo 19 Enterprise.

E

Escrito por

ECOSIRE Research and Development Team

Construyendo productos digitales de nivel empresarial en ECOSIRE. Compartiendo perspectivas sobre integraciones Odoo, automatización de eCommerce y soluciones empresariales impulsadas por IA.

Chatea en whatsapp