Skip to content

How to Create a Custom Odoo 19 Module: Step-by-Step Guide

DeployMonkey Team · March 22, 2026 18 min read

What You Will Build

A complete "Equipment Tracking" module with: a model for equipment items, form and list views, search filters, security rules, a menu entry, and basic tests. This covers every fundamental concept you need for Odoo module development.

Step 1: Module Structure

equipment_tracking/
├── __manifest__.py
├── __init__.py
├── models/
│   ├── __init__.py
│   └── equipment.py
├── views/
│   └── equipment_views.xml
├── security/
│   └── ir.model.access.csv
├── data/
│   └── equipment_data.xml
└── tests/
    ├── __init__.py
    └── test_equipment.py

Step 2: __manifest__.py

{
    'name': 'Equipment Tracking',
    'version': '19.0.1.0.0',
    'category': 'Operations',
    'summary': 'Track company equipment and assignments',
    'description': 'Manage equipment inventory, assignments, and maintenance schedules.',
    'author': 'Your Company',
    'depends': ['base', 'hr', 'mail'],
    'data': [
        'security/ir.model.access.csv',
        'views/equipment_views.xml',
        'data/equipment_data.xml',
    ],
    'installable': True,
    'application': True,
    'license': 'LGPL-3',
}

Step 3: Model

# models/__init__.py
from . import equipment

# models/equipment.py
from odoo import api, fields, models
from odoo.exceptions import ValidationError


class Equipment(models.Model):
    _name = 'equipment.item'
    _description = 'Equipment Item'
    _inherit = ['mail.thread', 'mail.activity.mixin']
    _order = 'name'

    name = fields.Char(string='Name', required=True, tracking=True)
    serial_number = fields.Char(string='Serial Number', required=True)
    department_id = fields.Many2one('hr.department', string='Department')
    assigned_to = fields.Many2one('hr.employee', string='Assigned To', tracking=True)
    status = fields.Selection([
        ('available', 'Available'),
        ('in_use', 'In Use'),
        ('maintenance', 'In Maintenance'),
        ('retired', 'Retired'),
    ], string='Status', default='available', tracking=True)
    purchase_date = fields.Date(string='Purchase Date')
    warranty_expiry = fields.Date(string='Warranty Expiry')
    purchase_cost = fields.Float(string='Purchase Cost')
    notes = fields.Text(string='Notes')

    # Computed field
    days_until_warranty = fields.Integer(
        string='Days Until Warranty Expiry',
        compute='_compute_days_until_warranty',
    )

    # Constraint (Odoo 19 style)
    _serial_unique = models.Constraint(
        "UNIQUE(serial_number)",
        "Serial number must be unique."
    )

    @api.depends('warranty_expiry')
    def _compute_days_until_warranty(self):
        today = fields.Date.today()
        for record in self:
            if record.warranty_expiry:
                delta = record.warranty_expiry - today
                record.days_until_warranty = delta.days
            else:
                record.days_until_warranty = 0

    @api.constrains('purchase_cost')
    def _check_purchase_cost(self):
        for record in self:
            if record.purchase_cost and record.purchase_cost < 0:
                raise ValidationError("Purchase cost cannot be negative.")

Step 4: Views

<!-- views/equipment_views.xml -->
<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <!-- List View -->
    <record id="equipment_item_view_list" model="ir.ui.view">
        <field name="name">equipment.item.list</field>
        <field name="model">equipment.item</field>
        <field name="arch" type="xml">
            <list decoration-danger="days_until_warranty < 30 and days_until_warranty > 0">
                <field name="name"/>
                <field name="serial_number"/>
                <field name="department_id"/>
                <field name="assigned_to"/>
                <field name="status" widget="badge"
                       decoration-success="status == 'available'"
                       decoration-info="status == 'in_use'"
                       decoration-warning="status == 'maintenance'"/>
                <field name="warranty_expiry"/>
            </list>
        </field>
    </record>

    <!-- Form View -->
    <record id="equipment_item_view_form" model="ir.ui.view">
        <field name="name">equipment.item.form</field>
        <field name="model">equipment.item</field>
        <field name="arch" type="xml">
            <form>
                <header>
                    <field name="status" widget="statusbar"
                           statusbar_visible="available,in_use,maintenance,retired"/>
                </header>
                <sheet>
                    <div class="oe_title">
                        <h1><field name="name" placeholder="Equipment Name"/></h1>
                    </div>
                    <group>
                        <group string="Details">
                            <field name="serial_number"/>
                            <field name="department_id"/>
                            <field name="assigned_to"/>
                        </group>
                        <group string="Purchase Info">
                            <field name="purchase_date"/>
                            <field name="purchase_cost"/>
                            <field name="warranty_expiry"/>
                            <field name="days_until_warranty"
                                   invisible="not warranty_expiry"/>
                        </group>
                    </group>
                    <notebook>
                        <page string="Notes" name="notes">
                            <field name="notes"/>
                        </page>
                    </notebook>
                </sheet>
                <div class="oe_chatter">
                    <field name="message_follower_ids"/>
                    <field name="activity_ids"/>
                    <field name="message_ids"/>
                </div>
            </form>
        </field>
    </record>

    <!-- Search View -->
    <record id="equipment_item_view_search" model="ir.ui.view">
        <field name="name">equipment.item.search</field>
        <field name="model">equipment.item</field>
        <field name="arch" type="xml">
            <search>
                <field name="name"/>
                <field name="serial_number"/>
                <filter name="available" string="Available" domain="[('status','=','available')]"/>
                <filter name="in_use" string="In Use" domain="[('status','=','in_use')]"/>
                <group string="Group By">
                    <filter name="group_dept" string="Department" context="{'group_by':'department_id'}"/>
                    <filter name="group_status" string="Status" context="{'group_by':'status'}"/>
                </group>
            </search>
        </field>
    </record>

    <!-- Action -->
    <record id="equipment_item_action" model="ir.actions.act_window">
        <field name="name">Equipment</field>
        <field name="res_model">equipment.item</field>
        <field name="view_mode">list,form</field>
    </record>

    <!-- Menu -->
    <menuitem id="equipment_menu_root" name="Equipment" sequence="100"/>
    <menuitem id="equipment_menu_items" name="Equipment Items"
              parent="equipment_menu_root" action="equipment_item_action"/>
</odoo>

Step 5: Security

# security/ir.model.access.csv
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_equipment_user,equipment.item.user,model_equipment_item,base.group_user,1,1,1,0
access_equipment_admin,equipment.item.admin,model_equipment_item,base.group_system,1,1,1,1

Step 6: Install and Test

# Install the module
python odoo-bin -d testdb -i equipment_tracking --stop-after-init

# Run tests
python odoo-bin -d testdb --test-tags /equipment_tracking --stop-after-init

AI-Powered Module Creation

Everything above can be generated by Claude Code in 10 minutes. Describe the module in natural language, and Claude creates all files with correct Odoo 19 syntax. Deploy the result to DeployMonkey with Git integration.