ITSkillsCenter
Business Digital

Odoo personnalisation et développement : guide pratique

17 min de lecture

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

  1. Setup environnement de dev
  2. Scaffolding d’un module via CLI
  3. Manifest __manifest__.py
  4. Modèle Python avec champs et méthodes
  5. Vues XML : list, form, kanban, search
  6. Sécurité : ir.model.access.csv
  7. Données initiales (data XML)
  8. Héritage d’un modèle existant (sale.order)
  9. Hooks pré/post-install
  10. Traduction française (.po)
  11. Debug : logs, breakpoints, shell
  12. 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')

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)

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.

Besoin d'un site web ?

Confiez-nous la Création de Votre Site Web

Site vitrine, e-commerce ou application web — nous transformons votre vision en réalité digitale. Accompagnement personnalisé de A à Z.

À partir de 250.000 FCFA
Parlons de Votre Projet
Publicité