Skip to content
Learni
View all tutorials
Développement Odoo

How to Develop a Custom Odoo Module in 2026

Lire en français

Introduction

Odoo, the leading open-source ERP, relies on an ultra-flexible modular system that lets you extend its features without touching the core. In 2026, with Odoo 18+, building a custom module is key to tailoring the ERP for complex business needs like multi-team project management with real-time tracking and automated invoicing.

This advanced tutorial walks you through creating the gestion_projets module, featuring: a Projet model inherited from mail.thread, one2many relationships to tasks, a computed field for total hours, a dynamic Kanban view, a wizard for bulk task assignment, and a secure REST controller. Every step provides complete, production-ready, optimized code.

Why it matters: Custom modules cut costs by 70% versus third-party apps, improve performance (thanks to ORM and OWL), and ensure data sovereignty. Ready to turn your specs into a scalable module?

Prerequisites

  • Odoo 18+ installed in developer mode (--dev=all)
  • PostgreSQL 15+ with a test database
  • Python 3.10+ and wkhtmltopdf for reports
  • Advanced knowledge: Odoo ORM, OWL framework, model inheritance
  • VS Code editor with Python/XML/Odoo extensions
  • Odoo shell access for odoo-bin scaffold

Generate the Module Structure

terminal
cd /path/to/your/odoo/addons
./odoo-bin scaffold gestion_projets .
# Edit __manifest__.py to activate the module after

The scaffold command creates the standard directory structure: models/, views/, security/, data/, wizards/. This prevents common structure errors. Activate in developer mode via Apps > Update Apps List, then install 'gestion_projets'.

Configure the Main Manifest

The __manifest__.py file defines metadata, dependencies, and auto-install settings. For advanced use, add 'assets': {'web.assets_backend': [...]}' for custom JS and 'external_dependencies': {'python': ['pandas']} if needed.

Define the Complete __manifest__.py

__manifest__.py
{
    'name': 'Gestion Projets Avancée',
    'version': '18.0.1.0.0',
    'category': 'Services/Project',
    'summary': 'Module avancé pour projets avec tracking heures et wizards',
    'description': """
    Gestion complète des projets :
    - Modèles hérités
    - Computed fields
    - Wizards one2many
    - API REST
    """,
    'author': 'Learni Dev',
    'website': 'https://learni-group.com',
    'depends': ['base', 'mail', 'project'],
    'data': [
        'security/ir.model.access.csv',
        'views/projet_views.xml',
        'views/projet_menu.xml',
        'wizards/assign_task_wizard.xml',
    ],
    'demo': ['data/demo.xml'],
    'installable': True,
    'auto_install': False,
    'application': True,
}

This manifest depends on 'project' for inheritance, loads views/menus in the correct order (security first), and marks it as an application for a menu icon. Pitfall: Wrong data[] order causes XML crashes. Version '18.0.1.0.0' follows Odoo conventions.

Create the Projet Model with Computed Field

models/projet.py
from odoo import models, fields, api

class Projet(models.Model):
    _name = 'gestion.projet'
    _inherit = ['mail.thread', 'mail.activity.mixin']
    _description = 'Projet Avancé'

    name = fields.Char('Nom Projet', required=True, tracking=True)
    client_id = fields.Many2one('res.partner', string='Client', required=True)
    total_heures = fields.Float('Total Heures', compute='_compute_total_heures', store=True)
    task_ids = fields.One2many('project.task', 'projet_id', string='Tâches')

    @api.depends('task_ids.heures_prevues')
    def _compute_total_heures(self):
        for record in self:
            record.total_heures = sum(task.heures_prevues for task in record.task_ids)

    def action_view_tasks(self):
        return {
            'type': 'ir.actions.act_window',
            'name': 'Tâches',
            'res_model': 'project.task',
            'view_mode': 'tree,form',
            'domain': [('projet_id', '=', self.id)],
        }

mail.thread inheritance enables discussions and chatter. The total_heures computed field is stored for performance (store=True) and depends on the one2many to project.task (custom field added via _inherit below). The action button opens a filtered view. Pitfall: Without @api.depends, recomputes fail.

Extend project.task for Integration

Analogy: Like grafting a branch onto an existing tree, inherit project.task to add projet_id and heures_prevues without breaking standard views.

Inherit and Extend project.task

models/project_task.py
from odoo import models, fields

class ProjectTask(models.Model):
    _inherit = 'project.task'

    projet_id = fields.Many2one('gestion.projet', string='Projet Parent')
    heures_prevues = fields.Float('Heures Prévue', default=8.0)

    def _compute_project_id(self):
        for task in self:
            if not task.projet_id:
                task.projet_id = self.env['gestion.projet'].search([('name', 'ilike', task.project_id.name)], limit=1)

Minimal extension: adds related fields. _compute_project_id auto-assigns via search (optimized with limit=1). Useful for migrating existing data. Pitfall: Inheritance without _inherit duplicates models.

Define XML Views (Tree, Form, Kanban)

views/projet_views.xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <record id="view_projet_tree" model="ir.ui.view">
        <field name="name">gestion.projet.tree</field>
        <field name="model">gestion.projet</field>
        <field name="arch" type="xml">
            <tree>
                <field name="name"/>
                <field name="client_id"/>
                <field name="total_heures"/>
                <button name="action_view_tasks" type="object" icon="fa-list"/>
            </tree>
        </field>
    </record>
    <record id="view_projet_form" model="ir.ui.view">
        <field name="name">gestion.projet.form</field>
        <field name="model">gestion.projet</field>
        <field name="arch" type="xml">
            <form>
                <sheet>
                    <group>
                        <field name="name"/>
                        <field name="client_id"/>
                        <field name="total_heures" readonly="1"/>
                    </group>
                    <notebook>
                        <page string="Tâches">
                            <field name="task_ids">
                                <tree editable="bottom">
                                    <field name="name"/>
                                    <field name="heures_prevues"/>
                                </tree>
                            </field>
                        </page>
                    </notebook>
                </sheet>
            </form>
        </field>
    </record>
    <record id="view_projet_kanban" model="ir.ui.view">
        <field name="name">gestion.projet.kanban</field>
        <field name="model">gestion.projet</field>
        <field name="arch" type="xml">
            <kanban>
                <templates>
                    <t t-name="kanban-box">
                        <div class="oe_kanban_card">
                            <div class="oe_kanban_details">
                                <strong><field name="name"/></strong>
                                <field name="total_heures"/>h
                            </div>
                        </div>
                    </t>
                </templates>
            </kanban>
        </field>
    </record>
</odoo>

Multi-mode views: tree with action button, form with editable one2many notebook, OWL-ready Kanban. readonly=1 on computed prevents manual edits. Pitfall: Without , parsing errors occur.

Security: Access Rights CSV

security/ir.model.access.csv
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_gestion_projet_user,gestion.projet.user,model_gestion_projet,project.group_project_user,1,0,0,0
access_gestion_projet_manager,gestion.projet.manager,model_gestion_projet,project.group_project_manager,1,1,1,1
access_project_task_projet,project.task.projet,model_project_task,project.group_project_user,1,1,1,1

CSV defines granular access: users read-only, managers full CRUD. Ties to 'project' groups. Pitfall: Missing this line causes 403 errors everywhere. Load before views.

Create Task Assignment Wizard

wizards/assign_task_wizard.py
from odoo import models, fields, api

class AssignTaskWizard(models.TransientModel):
    _name = 'assign.task.wizard'
    _description = 'Assigner Tâches à Projet'

    projet_id = fields.Many2one('gestion.projet', required=True)
    task_ids = fields.Many2many('project.task', string='Tâches à Assigner')

    def action_assign(self):
        self.task_ids.write({'projet_id': self.projet_id.id})
        return {'type': 'ir.actions.act_window_close'}

# XML action called from view

TransientModel wizard for batch actions. Many2many selects tasks, write() assigns in bulk. Returns close_window. Advanced: Add @api.model for context domains.

Wizard Views and Menu XML

wizards/assign_task_wizard.xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <record id="view_assign_task_wizard_form" model="ir.ui.view">
        <field name="name">assign.task.wizard.form</field>
        <field name="model">assign.task.wizard</field>
        <field name="arch" type="xml">
            <form>
                <group>
                    <field name="projet_id"/>
                    <field name="task_ids" widget="many2many_tags"/>
                </group>
                <footer>
                    <button name="action_assign" type="object" string="Assigner" class="btn-primary"/>
                </footer>
            </form>
        </field>
    </record>
    <record id="action_assign_task_wizard" model="ir.actions.act_window">
        <field name="name">Assigner Tâches</field>
        <field name="res_model">assign.task.wizard</field>
        <field name="view_mode">form</field>
        <field name="target">new</field>
    </record>
</odoo>

Wizard form with tags widget and primary footer button. Popup action (target=new). Call via

Add Main Menu

views/projet_menu.xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <menuitem id="menu_gestion_projets_root" name="Gestion Projets" sequence="10"/>
    <menuitem id="menu_projets" name="Projets" parent="menu_gestion_projets_root" action="action_projets" sequence="10"/>
</odoo>
<!-- action_projets generated by scaffold, or add:
<record id="action_projets" model="ir.actions.act_window">
    <field name="name">Projets</field>
    <field name="res_model">gestion.projet</field>
    <field name="view_mode">tree,form,kanban</field>
</record> -->

Nested menu under custom root. Sequence controls order. Multi-view action. Pitfall: No parent makes the menu orphaned.

Best Practices

  • Prefer Inheritance: Always extend existing models (e.g., project.task) over new ones for upgrade compatibility.
  • Computed store=True: Great for indexing/search, but watch DB triggers (max 5 dependencies).
  • Transient Wizards: Limit to <500 records to avoid timeouts.
  • Granular Security: Use standard Odoo groups + record_rules for sensitive data.
  • Unit Tests: Add tests/ with self.assertEqual(projet.total_heures, 40).

Common Errors to Avoid

  • Wrong data[] Order: Security after views = access denied. Always security first.
  • Incomplete @api.depends: Computed doesn't trigger on one2many → stale values.
  • XML Without Namespace: without = silent parse failure.
  • Wizard Memory Leaks: Many2many without domain loads 10k records and crashes.
  • No Tracking: Fields without tracking=True lose chatter history.

Next Steps

  • Official docs: Odoo 18 Dev Guide
  • OWL for custom JS views
  • Advanced QWeb reports
  • External API with controllers
Check out our expert Odoo training for masterclasses on inheritance and migrations.