VERSICH

How to Build a Custom Module in Odoo

how to build a custom module in odoo

Odoo ERP provides standard applications for sales, accounting, inventory, manufacturing, CRM, and other business operations. But sometimes a business needs functionality that its existing Odoo applications do not provide. It might need a specialized approval process, an equipment tracking tool, additional business rules, or a new screen for managing operational records. 

An Odoo module packages functionality into a structured, installable component. It can introduce new data models, extend existing applications, add user interfaces, automate business logic, or connect Odoo with external systems. 

In this article, you will learn how to build a custom module in Odoo by creating a simple equipment service request application. You will define a Python model, add a manifest file, configure access permissions, create list and form views, and install the module in an Odoo development environment. 

What You Need Before Building a Custom Odoo Module 

Before you start, make sure you have: 

  • A working Odoo development environment with access to the server's source code and configuration. 

  • Python fundamentals, including classes, imports, and dictionaries. 

  • Basic knowledge of XML. 

  • Access to a development database where you can install and test modules. 

  • A code editor and terminal. 

  • Permission to add custom modules to the Odoo addons path. 

If you're new to the platform, start with the official Odoo developer tutorials to understand its application architecture and development environment. 

Step 1: Define What Your Custom Module Will Do 

Before writing code, decide what the module needs to accomplish. 

For this article, we will create an Equipment Service Request module. It will allow internal users to record equipment issues and track their progress. 

The module will include: 

  • An equipment or issue title. 

  • A description of the problem. 

  • The employee responsible for handling the request. 

  • A requested service date. 

  • A status indicating whether the request is new, in progress, or completed. 

This gives us a practical example of a custom Odoo module with its own data model, fields, permissions, and user interface. 

In a real implementation, you would also document the business rules, user roles, reporting requirements, and any integrations needed before development begins. 

Step 2: Create the Module Directory 

Odoo looks for modules inside directories configured through its addons path. Each module is a Python package with a manifest that describes its metadata, dependencies, and data files. 

For this example, we'll name the module equipment_service_request. 

A typical directory structure is: 

equipment_service_request/
├── __init__.py
├── __manifest__.py
├── models/
│   ├── __init__.py
│   └── service_request.py
├── security/
│   └── ir.model.access.csv
├── tests/
│   ├── __init__.py
│   └── test_service_request.py
└── views/
    └── service_request_views.xml

Each file has a specific purpose: 

  • __init__.py imports Python packages so Odoo can load the module's code. 

  • __manifest__.py describes the module and lists the files Odoo should load. 

  • models/service_request.py defines the business data and fields. 

  • security/ir.model.access.csv defines model-level access permissions. 

  • tests/test_service_request.py contains automated tests for the module's business rules. 

  • views/service_request_views.xml defines the screens and navigation users will see. 

You can create this structure manually or use Odoo's scaffold command from the directory containing odoo-bin: 

./odoo-bin scaffold equipment_service_request /path/to/custom_addons

Replace /path/to/custom_addons with the actual path to your custom addons directory. If you use scaffolding, review the generated files and remove anything the module does not need. 

Step 3: Create the Module Manifest 

Create __manifest__.py in the root of the module directory: 

{
    "name": "Equipment Service Requests",
    "version": "19.0.1.0.0",
    "summary": "Track internal equipment service requests",
    "description": """
        Manage equipment service requests, assignments,
        requested dates, and request status.
    """,
    "category": "Operations",
    "license": "LGPL-3",
    "depends": ["base"],
    "data": [
        "security/ir.model.access.csv",
        "views/service_request_views.xml",
    ],
    "installable": True,
    "application": True,
}

The manifest tells Odoo how to identify and load your module. 

Here are the important entries: 

  • name: The module's display name in Odoo. 

  • version: The module version, aligned here with Odoo 19. 

  • depends: The Odoo modules required for this module to work. The base module provides core functionality. 

  • data: The XML and CSV files Odoo should load during installation or upgrade. 

  • installable: Indicates that the module can be installed. 

  • application: Identifies the module as an application rather than only a technical extension. 

File order matters. The access control file is listed before the views so that the model permissions are loaded before the user interface is defined. 

For a larger module, the manifest may also declare demo data, scheduled actions, reports, translations, or frontend assets. 

Step 4: Add the Python Imports 

Odoo must import your Python files to register the module's models. 

In the root __init__.py, add: 

from . import models

Then create models/__init__.py: 

from . import service_request 

These imports connect the module package to the Python file containing the model. If a model file is not imported, Odoo may not register the model when the module loads. 

Step 5: Define the Custom Odoo Model 

Create models/service_request.py: 

from odoo import fields, models


class EquipmentServiceRequest(models.Model):
    _name = "equipment.service.request"
    _description = "Equipment Service Request"
    _order = "requested_date desc, id desc"

    name = fields.Char(
        string="Request Title",
        required=True,
    )

    description = fields.Text(
        string="Issue Description",
    )

    assigned_user_id = fields.Many2one(
        comodel_name="res.users",
        string="Assigned To",
    )

    requested_date = fields.Date(
        string="Requested Date",
        default=fields.Date.today,
        required=True,
    )

    state = fields.Selection(
        selection=[
            ("new", "New"),
            ("in_progress", "In Progress"),
            ("done", "Completed"),
        ],
        string="Status",
        default="new",
        required=True,
    )

How the model works 

The class inherits from models.Model, which allows Odoo to register the model and manage its records through the ORM. 

The _name attribute defines the model's technical name. Here, it is equipment.service.request. Other parts of Odoo, including views and access control records, will refer to this model. 

The _description attribute provides a readable description, while _order determines the default record ordering. 

Each field defines a piece of information stored on the request: 

  • fields.Char stores a short text value. 

  • fields.Text stores a longer description. 

  • fields.Many2one links a request to one user record in res.users. 

  • fields.Date stores a date, with today's date as the default. 

  • fields.Selection restricts the status to the defined options. 

Odoo's ORM handles common database operations, field behavior, and model integration. You generally should not create database tables manually for ordinary Odoo models. 

For more advanced business requirements, you can add computed fields, constraints, relationships, automated actions, or methods that implement specific business rules. 

Step 6: Add Business Logic 

So far, the model only stores data. Most real modules also need rules: values calculated from other fields, and checks that stop users saving invalid records. We'll add one of each. 

First, update the import lines at the top of models/service_request.py: 

from odoo import api, fields, models
from odoo.exceptions import ValidationError

Then add a computed field below the state field: 

days_until_service = fields.Integer(
    string="Days Until Service",
    compute="_compute_days_until_service",
)

Finally, add these two methods at the bottom of the class: 

@api.depends("requested_date")
def _compute_days_until_service(self):
    today = fields.Date.today()

    for request in self:
        if request.requested_date:
            request.days_until_service = (
                request.requested_date - today
            ).days
        else:
            request.days_until_service = 0

@api.constrains("state", "assigned_user_id")
def _check_assigned_user_when_done(self):
    for request in self:
        if request.state == "done" and not request.assigned_user_id:
            raise ValidationError(
                "A completed request must have an assigned user."
            )

The computed field calculates how many days remain until the requested service date. The @api.depends decorator tells Odoo which field changes should trigger recalculation. Because the field is not stored, Odoo calculates it whenever it is displayed. 

The constraint enforces a business rule on the server. Odoo runs it whenever state or assigned_user_id changes, so the rule applies no matter how the record is saved. 

We'll display the new field in the list view in Step 8. 

Step 7: Configure Access Rights 

Defining a model does not automatically give every user permission to read or modify its records. You must configure the appropriate access rights. 

Create security/ir.model.access.csv: 

id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_equipment_service_request_user,equipment.service.request user,model_equipment_service_request,base.group_user,1,1,1,1

This access control entry grants internal users read, write, create, and delete permissions on the model. 

The model external ID is generated from the technical model name, which is why equipment.service.request is referenced as model_equipment_service_request.

Security note: These permissions are intentionally broad for this demonstration. They allow internal users to access all records in the model, subject to other applicable rules. A production module may need narrower access rights, security groups, and record rules so that users can only access records appropriate to their role or company. 

Never treat a hidden menu or button as a security control. Permissions must be enforced by Odoo's security framework. 

Step 8: Create the List, Form, and Search Views, and Menus 

The model defines the data, but users need an interface to work with it. 

Create views/service_request_views.xml: 

<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <record id="view_equipment_service_request_list" model="ir.ui.view">
        <field name="name">equipment.service.request.list</field>
        <field name="model">equipment.service.request</field>
        <field name="arch" type="xml">
            <list string="Service Requests">
                <field name="name"/>
                <field name="requested_date"/>
                <field name="assigned_user_id"/>
                <field name="days_until_service"/>
                <field name="state"/>
            </list>
        </field>
    </record>

    <record id="view_equipment_service_request_form" model="ir.ui.view">
        <field name="name">equipment.service.request.form</field>
        <field name="model">equipment.service.request</field>
        <field name="arch" type="xml">
            <form string="Service Request">
                <sheet>
                    <group>
                        <field name="name"/>
                        <field name="requested_date"/>
                        <field name="assigned_user_id"/>
                        <field name="state"/>
                    </group>
                    <group>
                        <field name="description"/>
                    </group>
                </sheet>
            </form>
        </field>
    </record>

    <record id="view_equipment_service_request_search" model="ir.ui.view">
        <field name="name">equipment.service.request.search</field>
        <field name="model">equipment.service.request</field>
        <field name="arch" type="xml">
            <search string="Search Service Requests">
                <field name="name"/>
                <field name="assigned_user_id"/>
                <filter name="filter_new" string="New"
                        domain="[('state', '=', 'new')]"/>
                <filter name="filter_in_progress" string="In Progress"
                        domain="[('state', '=', 'in_progress')]"/>
                <filter name="filter_my_requests" string="My Requests"
                        domain="[('assigned_user_id', '=', uid)]"/>
                <group string="Group By">
                    <filter name="group_by_state" string="Status"
                            context="{'group_by': 'state'}"/>
                    <filter name="group_by_assigned" string="Assigned To"
                            context="{'group_by': 'assigned_user_id'}"/>
                </group>
            </search>
        </field>
    </record>

    <record id="action_equipment_service_request" model="ir.actions.act_window">
        <field name="name">Service Requests</field>
        <field name="res_model">equipment.service.request</field>
        <field name="view_mode">list,form</field>
    </record>

    <menuitem
        id="menu_equipment_service_root"
        name="Equipment Service"
        sequence="10"
    />

    <menuitem
        id="menu_equipment_service_requests"
        name="Service Requests"
        parent="menu_equipment_service_root"
        action="action_equipment_service_request"
        sequence="10"
    />
</odoo>

This file creates five main components: 

  1. List view: Displays multiple service requests in a table-like interface. 

  1. Form view: Allows users to open and edit an individual request. 

  1. Search view: Lets users search by title or assignee, filter by status, and group requests. 

  1. Window action: Tells Odoo which model to display when the action is selected. 

  1. Menus: Add a navigation entry through which users can open the requests. 

This example uses the <list> view syntax for Odoo 19. When targeting an older release, verify the appropriate syntax and supported attributes in that version's documentation. 

At this point, the module contains its core model, access rights, and user interface. You can extend the views later with status buttons, kanban views, or additional fields. 

Step 9: Install the Custom Module in Odoo 

Once the files are in place, install the module in your development environment. 

1. Check the addons path 

Confirm that the directory containing equipment_service_request is included in the Odoo server's configured addons paths. If it is not, update the configuration and restart the server as appropriate for your environment. 

2. Update the apps list 

Sign in to Odoo with an administrator account. Enable developer mode if necessary, open the Apps menu, and update the apps list so Odoo can discover the new module. 

3. Find and install the module 

Search for Equipment Service Requests in Apps. Clear any filters that might hide custom modules, open the module, and click Install. 

4. Test the interface 

Open the new Equipment Service menu and create a sample request. Enter a title, description, requested date, and assigned user. Save the record and verify that it appears in the list view. 

If the module does not appear or fails to install, check the server logs for the underlying error instead of repeatedly reinstalling it. 

For command-line workflows, you can install the module with the -i option and update it later with -u: 

./odoo-bin -d your_dev_db --addons-path=addons,/path/to/custom_addons -i equipment_service_request --stop-after-init

After changing the code or views, upgrade the module: 

./odoo-bin -d your_dev_db --addons-path=addons,/path/to/custom_addons -u equipment_service_request --stop-after-init

Replace your _dev_db and the addons paths with your own values. Run these only against a development database. 

Step 10: Test the Module Before Using It in Production 

A module that installs successfully is not necessarily ready for production. 

Test the complete workflow, including: 

  • Creating a request with all required fields. 

  • Saving a request with an optional description left blank. 

  • Confirming that the requested date defaults correctly. 

  • Assigning a valid user. 

  • Verifying the available status values. 

  • Checking that the list and form views display the expected information. 

  • Confirming that users without the required permissions cannot perform restricted operations. 

  • Testing the module against the Odoo version and other installed modules used by the business. 

For more complex modules, add automated tests for business logic, constraints, and important workflows. Test changes in a development or staging database before deploying them to production. 

Write a basic automated test 

Add a tests folder to the module, as shown in the directory structure in Step 2. 

In tests/__init__.py: 

from . import test_service_request

In tests/test_service_request.py: 

from datetime import timedelta

from odoo import fields
from odoo.exceptions import ValidationError
from odoo.tests.common import TransactionCase


class TestServiceRequest(TransactionCase):

    def test_default_state_is_new(self):
        request = self.env["equipment.service.request"].create({
            "name": "Broken printer",
        })
        self.assertEqual(request.state, "new")

    def test_days_until_service(self):
        request = self.env["equipment.service.request"].create({
            "name": "Generator check",
            "requested_date": fields.Date.today() + timedelta(days=5),
        })
        self.assertEqual(request.days_until_service, 5)

    def test_done_request_requires_assigned_user(self):
        with self.assertRaises(ValidationError):
            self.env["equipment.service.request"].create({
                "name": "Air conditioner repair",
                "state": "done",
            })

Run the tests on a development database: 

./odoo-bin -d your_test_db --addons-path=addons,/path/to/custom_addons -i equipment_service_request --test-tags /equipment_service_request --stop-after-init

The test results appear in the server log. The three tests check the default status, the computed field, and the constraint. 

Step 11: Maintain the Module as Odoo Evolves 

Custom module development does not end after installation. Odoo upgrades, dependency changes, new business requirements, and changes to existing workflows can all affect custom code. 

Use version control to track changes and review them before deployment. Keep the module's dependencies explicit, document important business rules, and test upgrades against a copy of the target database. 

Avoid modifying Odoo's core source code when an extension through a custom module can achieve the same result. Core modifications are harder to maintain and can complicate future upgrades. 

When a module extends an existing Odoo model, use supported inheritance mechanisms and keep custom logic focused. For integrations, consider authentication, data validation, retries, duplicate prevention, and logging as part of the design rather than adding them only after a failure. 

Common Problems When Building a Custom Odoo Module 

The module does not appear in Apps 

Check that the module folder is in an active addons path, that __manifest__.py exists, and that the apps list has been updated. Also confirm that the module is marked as installable. 

Odoo cannot find the model 

Verify that both __init__.py files import the relevant Python packages and that the model's technical name is consistent across the code, views, and access control file. 

Installation fails because of an XML error 

Review the XML file for missing closing tags, incorrect external IDs, invalid field names, or references to records that have not been loaded. The server log usually points to the file or record that caused the failure. 

Users cannot open the model 

Check the access control CSV, model external ID, and the user's assigned groups. If record rules are present, review them too: model-level access rights and record rules work together. 

Changes do not appear after editing the code 

Depending on the change, you may need to restart the server, upgrade the module, refresh the browser, or clear an asset cache. XML data and view definitions often require a module upgrade; Python changes may require the server process to reload. 

When Should You Build a Custom Odoo Module? 

A custom module makes sense when standard Odoo configuration and existing applications cannot adequately support a specific business requirement. 

Good candidates include specialized approval processes, additional business records, industry-specific workflows, custom reports, or business logic that must run consistently across multiple transactions. 

However, not every requirement needs custom code. Standard configuration or Odoo Studio may be enough for simpler changes. Before developing a module, compare the requirement with existing Odoo functionality and consider the maintenance and upgrade implications. 

For a broader overview of when customization is appropriate, read our guide to Odoo ERP customization.   

Conclusion 

Building a custom module in Odoo involves more than writing a Python class. A working module combines a manifest, Python imports, data models, access permissions, views, and a reliable installation and testing process. 

The Equipment Service Request example demonstrates the essential structure. From here, you can add business rules, automated actions, reporting, relationships with other models, or integrations with external systems. 

The best custom Odoo modules solve a clearly defined problem while remaining secure, maintainable, and compatible with the wider ERP environment. Start with standard functionality, add custom code only where it provides real value, and test every change before deploying it to production. 

If your business needs functionality beyond standard Odoo applications, Talk to us about custom Odoo development