Lecture : 16 minutes · Niveau : avancé · Mise à jour : avril 2026
Tutoriel pratique pour créer un module Odoo custom de A à Z : scaffolding CLI, déclaration manifest, modèle Python avec champs et méthodes, vues XML, héritage de modèle existant, hooks pré/post-install, traduction française, debug. Tout est testé sur Odoo 17 Community en Docker. À la fin, vous avez un module pme_extension opérationnel avec 2 modèles custom, vues form/list/kanban, et un workflow d’approbation.
Pourquoi développer un module custom plutôt que tout faire via Studio ou des configurations standards ? Studio est puissant mais cadenasse les changements dans la base de données et complique les migrations de version. Un module custom versionné en Git est plus pérenne, peut être réutilisé entre clients, testé automatiquement, et appliqué à plusieurs instances Odoo de manière reproductible. Pour une PME qui démarre, Studio peut suffire pour 6-12 mois. Au-delà, dès qu’on accumule plus de 5-10 personnalisations, basculer vers du code Python devient rentable en termes de maintenance.
Architecture d’un module Odoo en bref. Un module est un dossier Python contenant : un __manifest__.py (carte d’identité), des modèles Python (models/) qui définissent les structures de données et la logique métier, des vues XML (views/) qui décrivent l’interface utilisateur, des règles de sécurité (security/), des données initiales (data/), et optionnellement des contrôleurs HTTP (controllers/), des templates web (templates/), des assets statiques (static/). L’ORM Odoo est très expressif : la majorité de la logique métier tient en quelques dizaines de lignes Python.
Voir aussi → Odoo PME Afrique : guide complet, Odoo modules essentiels pour PME, Odoo paiement mobile money intégration.
Sommaire
- Setup environnement de dev
- Scaffolding d’un module via CLI
- Manifest
__manifest__.py - Modèle Python avec champs et méthodes
- Vues XML : list, form, kanban, search
- Sécurité : ir.model.access.csv
- Données initiales (data XML)
- Héritage d’un modèle existant (sale.order)
- Hooks pré/post-install
- Traduction française (.po)
- Debug : logs, breakpoints, shell
- FAQ
1. Setup environnement de dev
Repartir du Docker Compose du tutoriel précédent (Odoo modules essentiels) avec montage du dossier addons :
volumes:
- ./addons:/mnt/extra-addons
mkdir -p addons
docker compose up -d
Activer le mode développeur : Settings → Activate the developer mode (en bas de page).
Outils complémentaires recommandés. Côté éditeur : VS Code avec les extensions Python, Odoo Snippets, et XML Tools. Côté Git : pre-commit configuré avec ruff (lint Python rapide), flake8, et pylint-odoo (règles spécifiques Odoo qui détectent les anti-patterns). Côté DB : pgAdmin ou DBeaver pour explorer le schéma PostgreSQL pendant le développement. Pour les très gros modules : odoo-bin shell reste l’outil le plus efficace pour valider une expression ORM avant de l’écrire en méthode.
Activer les logs :
docker compose logs -f odoo --tail=100
2. Scaffolding d’un module via CLI
Odoo fournit une commande de scaffolding :
docker compose exec odoo \
odoo scaffold pme_extension /mnt/extra-addons
Structure générée :
addons/pme_extension/
├── __init__.py
├── __manifest__.py
├── controllers/
├── demo/
├── models/
├── security/
└── views/
3. Manifest __manifest__.py
{
'name': 'PME Extension',
'version': '17.0.1.0.0',
'summary': 'Personnalisations PME Afrique de l Ouest',
'description': """
Module ajoutant :
- Modèle de demande d'approbation interne
- Champ N° Registre de commerce sur les sociétés
- Workflow validation devis > 5M FCFA
""",
'category': 'Tools',
'author': 'PME ITSkillsCenter',
'website': 'https://itskillscenter.io',
'license': 'LGPL-3',
'depends': ['base', 'sale', 'mail'],
'data': [
'security/ir.model.access.csv',
'views/approval_request_views.xml',
'views/menus.xml',
'data/approval_data.xml',
],
'demo': [
'demo/approval_demo.xml',
],
'installable': True,
'application': True,
'auto_install': False,
}
4. Modèle Python avec champs et méthodes
addons/pme_extension/models/__init__.py :
from . import approval_request
from . import res_partner_extension
addons/pme_extension/models/approval_request.py :
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class ApprovalRequest(models.Model):
_name = 'pme.approval.request'
_description = 'Demande d\'approbation interne'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'create_date desc'
name = fields.Char(string='Référence', readonly=True,
default=lambda self: _('New'))
title = fields.Char(string='Objet', required=True, tracking=True)
description = fields.Html(string='Description')
requester_id = fields.Many2one('res.users', string='Demandeur',
default=lambda self: self.env.user,
required=True)
approver_id = fields.Many2one('res.users', string='Approbateur',
required=True, tracking=True)
amount = fields.Monetary(string='Montant', currency_field='currency_id')
currency_id = fields.Many2one('res.currency', string='Devise',
default=lambda self:
self.env.company.currency_id)
state = fields.Selection([
('draft', 'Brouillon'),
('submitted', 'Soumise'),
('approved', 'Approuvée'),
('rejected', 'Rejetée'),
], string='Statut', default='draft', tracking=True)
deadline = fields.Date(string='Échéance')
@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(
'pme.approval.request') or _('New')
return super().create(vals_list)
def action_submit(self):
for rec in self:
if not rec.title:
raise ValidationError(_('L\'objet est requis.'))
rec.state = 'submitted'
rec.message_post(body=_('Demande soumise pour approbation.'))
def action_approve(self):
for rec in self:
if self.env.user != rec.approver_id and \
not self.env.user.has_group('base.group_system'):
raise ValidationError(_(
'Seul l\'approbateur désigné peut approuver.'))
rec.state = 'approved'
def action_reject(self):
self.write({'state': 'rejected'})
@api.constrains('amount')
def _check_amount_positive(self):
for rec in self:
if rec.amount < 0:
raise ValidationError(_('Le montant doit être positif.'))
addons/pme_extension/models/res_partner_extension.py (héritage) :
from odoo import fields, models
class ResPartner(models.Model):
_inherit = 'res.partner'
rc_number = fields.Char(string='N° Registre du Commerce')
ninea = fields.Char(string='NINEA',
help='Identifiant fiscal Sénégal')
is_local_partner = fields.Boolean(
string='Partenaire local',
compute='_compute_is_local',
store=True)
def _compute_is_local(self):
for p in self:
p.is_local_partner = (
p.country_id and p.country_id.code == 'SN')
5. Vues XML : list, form, kanban, search
addons/pme_extension/views/approval_request_views.xml :
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- LIST -->
<record id="view_approval_list" model="ir.ui.view">
<field name="name">pme.approval.request.list</field>
<field name="model">pme.approval.request</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="title"/>
<field name="requester_id"/>
<field name="amount" sum="Total"/>
<field name="currency_id" optional="hide"/>
<field name="deadline"/>
<field name="state" decoration-success="state=='approved'"
decoration-danger="state=='rejected'"
decoration-info="state=='submitted'"/>
</list>
</field>
</record>
<!-- FORM -->
<record id="view_approval_form" model="ir.ui.view">
<field name="name">pme.approval.request.form</field>
<field name="model">pme.approval.request</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_submit" type="object" string="Soumettre"
invisible="state != 'draft'" class="btn-primary"/>
<button name="action_approve" type="object" string="Approuver"
invisible="state != 'submitted'" class="btn-success"/>
<button name="action_reject" type="object" string="Rejeter"
invisible="state != 'submitted'"/>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group>
<field name="title"/>
<field name="requester_id"/>
<field name="approver_id"/>
</group>
<group>
<field name="amount"/>
<field name="currency_id"/>
<field name="deadline"/>
</group>
</group>
<notebook>
<page string="Description">
<field name="description"/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- KANBAN -->
<record id="view_approval_kanban" model="ir.ui.view">
<field name="name">pme.approval.request.kanban</field>
<field name="model">pme.approval.request</field>
<field name="arch" type="xml">
<kanban default_group_by="state">
<field name="title"/>
<field name="amount"/>
<field name="currency_id"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_card">
<strong><field name="title"/></strong>
<div><field name="amount" widget="monetary"/></div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ACTION + MENU -->
<record id="action_approval" model="ir.actions.act_window">
<field name="name">Demandes d'approbation</field>
<field name="res_model">pme.approval.request</field>
<field name="view_mode">kanban,list,form</field>
</record>
</odoo>
addons/pme_extension/views/menus.xml :
<odoo>
<menuitem id="menu_pme_root" name="PME Extension"
web_icon="pme_extension,static/description/icon.png"/>
<menuitem id="menu_pme_approval" name="Approbations"
parent="menu_pme_root"
action="action_approval"
sequence="10"/>
</odoo>
6. Sécurité : ir.model.access.csv
addons/pme_extension/security/ir.model.access.csv :
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_pme_approval_user,pme.approval.request user,model_pme_approval_request,base.group_user,1,1,1,0
access_pme_approval_manager,pme.approval.request manager,model_pme_approval_request,base.group_system,1,1,1,1
Pour des droits plus fins (record rules) :
<record id="rule_approval_own" model="ir.rule">
<field name="name">Voir uniquement ses demandes</field>
<field name="model_id" ref="model_pme_approval_request"/>
<field name="domain_force">
[('requester_id','=',user.id),('approver_id','=',user.id)]
</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
</record>
7. Données initiales (data XML)
addons/pme_extension/data/approval_data.xml :
<odoo noupdate="1">
<record id="seq_approval_request" model="ir.sequence">
<field name="name">Demande d'approbation</field>
<field name="code">pme.approval.request</field>
<field name="prefix">APP/%(year)s/</field>
<field name="padding">5</field>
</record>
</odoo>
8. Héritage d’un modèle existant (sale.order)
Ajouter une validation custom sur les devis > 5M FCFA :
# addons/pme_extension/models/sale_order_extension.py
from odoo import api, models, _
from odoo.exceptions import UserError
class SaleOrder(models.Model):
_inherit = 'sale.order'
def action_confirm(self):
threshold = 5_000_000 # FCFA
for order in self:
if order.amount_total > threshold:
if not self.env.user.has_group('base.group_system'):
raise UserError(_(
'Devis %s > %s : nécessite approbation manager.'
) % (order.name, threshold))
return super().action_confirm()
9. Hooks pré/post-install
addons/pme_extension/__init__.py :
from . import models
def post_init_hook(env):
"""Crée des données après installation."""
Country = env['res.country']
senegal = Country.search([('code', '=', 'SN')], limit=1)
if senegal:
env['ir.config_parameter'].sudo().set_param(
'pme_extension.default_country_id', senegal.id)
Dans __manifest__.py :
'post_init_hook': 'post_init_hook',
10. Traduction française (.po)
# Générer le template
docker compose exec odoo odoo --stop-after-init \
-d pme-test -i pme_extension \
--i18n-export=/mnt/extra-addons/pme_extension/i18n/pme_extension.pot \
--modules=pme_extension
Copier pme_extension.pot en fr_FR.po et traduire :
#. module: pme_extension
#: model:ir.model.fields,field_description:pme_extension.field_pme_approval_request__title
msgid "Object"
msgstr "Objet"
Charger : Settings → Translations → Load a Translation → French → Save → Synchronize.
Conseil pratique : garder une cohérence terminologique avec les modules officiels Odoo. Si Odoo utilise « Devis » pour sale.order au stade brouillon, ne pas réinventer en « Proposition commerciale ». Cela évite la confusion utilisateur et facilite la formation. Maintenir un fichier glossary.md dans le repo Git du module qui liste les termes choisis et leur traduction.
Synchroniser les traductions après chaque modification : dès qu’on ajoute un champ ou une méthode avec un nouveau libellé, regénérer le .pot puis fusionner dans le .po existant avec msgmerge pour ne pas perdre les traductions précédentes. Automatiser ce processus dans la CI évite les régressions de traduction au fil des sprints.
11. Debug : logs, breakpoints, shell
Logs en temps réel :
docker compose logs -f odoo | grep -E "ERROR|WARNING|pme_extension"
Lecture des logs Odoo : chaque ligne suit le format date heure pid niveau db logger: message. Filtrer sur le pid permet de suivre une requête spécifique. Le logger odoo.sql_db montre toutes les requêtes SQL exécutées, très utile pour identifier un N+1 ou une requête mal optimisée. Activer --log-sql dump intégral, à utiliser uniquement en debug court car cela inonde les logs.
Niveau de log spécifique :
docker compose exec odoo odoo \
--log-level=debug --log-handler=odoo.addons.pme_extension:DEBUG
Shell interactif Python sur la DB :
docker compose exec odoo odoo shell -d pme-test
>>> partners = self.env['res.partner'].search([('country_id.code','=','SN')])
>>> len(partners)
142
>>> partners[0].name
'Diop & Frères SARL'
>>> self.env.cr.commit()
Mise à jour du module après modif Python :
docker compose exec odoo odoo \
-d pme-test -u pme_extension --stop-after-init
docker compose restart odoo
Breakpoint avec pdb :
import pdb; pdb.set_trace()
Puis attacher la console : docker compose exec odoo odoo (l’interaction se fait via le terminal qui a démarré Odoo).
FAQ
Studio ou code custom ?
Studio est rapide pour ajouter un champ ou une vue simple, mais limité. Pour de la logique métier, des héritages multiples, des automatisations cross-module : code custom (Python + XML). Beaucoup d’équipes utilisent Studio pour prototypage puis ré-écrivent en code une fois la solution stabilisée.
Comment versionner mes modules custom ?
Git. Chaque module dans son repo ou un monorepo addons/. Branches par version Odoo (17.0, 18.0). Tags pour les releases. CI : tests unitaires odoo-bin --test-enable -d test_db -i pme_extension --stop-after-init.
Quelle compatibilité entre versions Odoo ?
Pas de rétro-compat garantie. Un module 17.0 ne fonctionne pas sur 16.0 sans adaptations. Migrer un module : OpenUpgrade, OCA, ou réécriture manuelle. Suivre les release notes Odoo pour les changements d’API.
Comment tester sans casser la prod ?
Toujours développer sur une copie staging de la prod. Pipeline type : dev locale → push Git → déploiement staging via CI → tests fonctionnels → merge main → déploiement prod. Voir DevOps moderne CI/CD IaC.
Faut-il maîtriser Owl (le framework JS d’Odoo) ?
Pour des vues custom backoffice avancées et des widgets riches : oui. Pour 90% des besoins PME (champs, vues form/list/kanban classiques) : non. Owl ressemble à React, l’apprentissage est rapide pour un dev frontend moderne.
Comment publier un module sur Odoo Apps Store ?
Compte développeur Odoo, conformité aux guidelines (qualité code, sécurité, doc), pricing (gratuit ou payant avec commission Odoo). Process de revue 1-4 semaines. Permet de monétiser un module utile à de nombreuses PME.
Comment debug une vue qui ne s’affiche pas ?
Activer le mode développeur, F12 console navigateur (erreurs JS), docker compose logs odoo | grep -i error, vérifier que la vue est bien chargée (Settings → Technical → User Interface → Views), forcer mise à jour : -u pme_extension --stop-after-init.
XML-RPC ou JSON-RPC ?
JSON-RPC (/jsonrpc) plus moderne, structuré, mieux supporté par les frameworks récents. XML-RPC plus universel et compatible avec les clients legacy. Préférer JSON-RPC pour de nouveaux développements. Voir aussi Odoo modules essentiels pour PME section XML-RPC.
Articles liés (cluster Odoo)
- 👉 Odoo PME Afrique : guide complet
- 👉 Odoo modules essentiels pour PME
- 👉 Odoo paiement mobile money intégration
Voir aussi : Python pour PME : guide pratique pour les fondamentaux Python, DevOps moderne CI/CD IaC pour la CI/CD Odoo.
Article mis à jour le 25 avril 2026. Pour signaler une erreur ou suggérer une amélioration, écrivez-nous.