Skip to content
Learni
View all tutorials
Développement Odoo

Comment développer un module Odoo personnalisé en 2026

Introduction

Odoo, l'ERP open-source leader, repose sur un système modulaire ultra-flexible qui permet d'étendre ses fonctionnalités sans toucher au core. En 2026, avec Odoo 18+, développer un module personnalisé est essentiel pour adapter l'ERP aux besoins métier complexes comme la gestion de projets multi-équipes avec tracking temps réel et facturation automatisée.

Ce tutoriel avancé vous guide dans la création du module gestion_projets, intégrant : un modèle Projet hérité de mail.thread, des relations one2many vers tâches, un champ computed pour le total d'heures, une vue Kanban dynamique, un wizard pour assigner des tâches en masse, et un contrôleur REST sécurisé. Chaque étape inclut du code complet, fonctionnel et optimisé pour la production.

Pourquoi c'est crucial ? Les modules custom réduisent les coûts de 70% vs. apps tierces, boostent les perfs (ORM OWL) et assurent la souveraineté des données. Prêt à transformer vos specs en module scalable ? (128 mots)

Prérequis

  • Odoo 18+ installé en mode développeur (--dev=all)
  • PostgreSQL 15+ avec base de test
  • Python 3.10+ et wkhtmltopdf pour reports
  • Connaissances avancées : ORM Odoo, OWL framework, héritage modèles
  • Éditeur VS Code avec extensions Python/XML/Odoo
  • Accès shell Odoo pour odoo-bin scaffold

Générer la structure du module

terminal
cd /path/to/your/odoo/addons
./odoo-bin scaffold gestion_projets .
# Éditez __manifest__.py pour activer le module après

La commande scaffold génère l'arborescence standard : models/, views/, security/, data/, wizards/. Cela évite les erreurs de structure courantes. Activez en mode dev via Apps > Mettre à jour la liste des apps, puis installez 'gestion_projets'.

Configurer le manifest principal

Le fichier __manifest__.py définit les métadonnées, dépendances et auto-install. Pour advanced, ajoutez 'assets': {'web.assets_backend': [...]}' pour JS custom et 'external_dependencies': {'python': ['pandas']}` si besoin.

Définir __manifest__.py complet

__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,
}

Ce manifest dépend de 'project' pour héritage, charge les vues/menus en ordre (security d'abord), et marque comme application pour icône menu. Piège : ordre des data[] faux = crash XML. Version '18.0.1.0.0' suit convention Odoo.

Créer le modèle Projet avec computed

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)],
        }

Héritage mail.thread active discussions/chatter. Computed total_heures stocké pour perfs (store=True), dépend de one2many vers project.task (custom field ajouté via _inherit plus bas). Action button ouvre vue filtrée. Piège : sans @api.depends, recompute foireux.

Étendre project.task pour intégration

Analogie : Comme greffer une branche sur un arbre existant, on hérite project.task pour ajouter projet_id et heures_prevues sans casser les vues standard.

Hériter et étendre 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)

Extension minimaliste : ajoute champs liés. _compute_project_id auto-assign via search (optimisé limit=1). Utile pour migration données existantes. Piège : héritage sans _inherit = duplication modèles.

Définir les vues XML (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>

Vues multi-mode : tree avec button action, form avec notebook one2many editable, kanban OWL-ready. readonly=1 sur computed évite edits manuels. Piège : sans , parse error.

Sécurité : droits d'accès 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 définit accès granulaires : user lit-only, manager full CRUD. Lie à groupes 'project'. Piège : sans ligne, 403 errors partout. Charge avant vues.

Créer wizard d'assignation tâches

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

# Action XML appelée depuis vue

Wizard TransientModel pour actions batch. Many2many sélectionne tâches, write() assigne en masse. Retour close_window. Advanced : ajoutez @api.model pour context domain.

Vues wizard et 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>

Form wizard avec tags widget, button footer-primary. Action popup (target=new). Appelez via

Ajouter menu principal

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 généré par scaffold, ou ajoutez :
<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> -->

Menu imbriqué sous root custom. Sequence contrôle ordre. Action multi-view. Piège : sans parent, menu orphelin.

Bonnes pratiques

  • Héritage préférentiel : Toujours étendre modèles existants (ex. project.task) vs. nouveaux pour compatibilité upgrades.
  • Computed store=True : Pour index/search, mais surveillez triggers DB (max 5 dépends).
  • Wizards Transient : Limitez à <500 records pour éviter timeouts.
  • Sécurité granulaire : Groupes Odoo standards + record_rules pour RIB.
  • Tests unitaires : Ajoutez tests/ avec self.assertEqual(projet.total_heures, 40).

Erreurs courantes à éviter

  • Ordre data[] faux : Security après views = accès denied. Toujours security first.
  • @api.depends incomplet : Computed non-triggeré sur one2many → valeurs obsolètes.
  • XML sans namespace : sans = parse fail silencieux.
  • Mémoire leaks wizards : Many2many sans domain = chargement 10k records crash.
  • Pas de tracking : Champs sans tracking=True perdent historique chatter.

Pour aller plus loin

  • Docs officielles : Odoo 18 Dev Guide
  • OWL pour vues JS custom
  • Reports QWeb avancés
  • API externe avec controllers
Découvrez nos formations Odoo expertes pour masterclass héritage et migrations.