Création de modules Odoo personnalisés : didacticiel du développeur
Le système de modules d'Odoo est l'un des frameworks d'extension les plus puissants du monde ERP. Chaque fonctionnalité d'Odoo — de la comptabilité à l'inventaire en passant par le CRM — est un module. Cela signifie que la création de fonctionnalités personnalisées suit exactement les mêmes modèles utilisés par les propres développeurs d'Odoo, vous donnant accès au framework complet sans en modifier le noyau.
Ce didacticiel couvre le cycle de vie complet d'un module Odoo 19 personnalisé : depuis l'échafaudage de la structure des répertoires et la définition des modèles, jusqu'à la création de vues, la sécurisation de l'accès et le déploiement en production. À la fin, vous disposerez d'un module fonctionnel qui suit les conventions d'Odoo 19 Enterprise et est prêt pour le marché.
Points clés à retenir
- Chaque module Odoo est un package Python avec un descripteur
__manifest__.py- Les modèles héritent de
models.Modelet mappent directement aux tables PostgreSQL- Les vues sont définies dans les champs XML et du modèle de référence par leur nom
- La sécurité est renforcée via
ir.model.accessCSV et les règles d'enregistrement- Les assistants (
TransientModel) gèrent les interactions utilisateur en plusieurs étapes- Les champs calculés et les méthodes onchange mettent à jour les champs associés de manière dynamique
- Les actions automatisées et les tâches planifiées exécutent une logique côté serveur sur les déclencheurs
- Les dépendances des modules garantissent un ordre de chargement correct et la disponibilité des fonctionnalités
Structure du module et échafaudage
Chaque module Odoo est un répertoire avec une structure spécifique. Utilisez la commande d'échafaudage intégrée d'Odoo pour générer le passe-partout :
# From your Odoo addons directory
python odoo-bin scaffold my_module /path/to/addons
Cela génère :
my_module/
├── __init__.py
├── __manifest__.py
├── models/
│ ├── __init__.py
│ └── my_model.py
├── views/
│ └── my_model_views.xml
├── security/
│ ├── ir.model.access.csv
│ └── my_module_security.xml
├── data/
│ └── my_module_data.xml
├── wizard/
│ └── my_wizard.py
├── report/
│ └── my_report.xml
└── static/
└── src/
└── js/
Le fichier manifeste (__manifest__.py)
{
'name': 'My Custom Module',
'version': '19.0.1.0.0',
'summary': 'Short description for module list',
'description': """
Extended description of what this module does.
Can be multi-line RST text.
""",
'author': 'ECOSIRE Private Limited',
'website': 'https://ecosire.com',
'category': 'Sales/CRM',
'depends': ['sale', 'account', 'stock'],
'data': [
'security/ir.model.access.csv',
'security/my_module_security.xml',
'data/my_module_data.xml',
'views/my_model_views.xml',
'views/menu_views.xml',
'report/my_report.xml',
'wizard/my_wizard_views.xml',
],
'assets': {
'web.assets_backend': [
'my_module/static/src/js/my_widget.js',
'my_module/static/src/css/my_styles.css',
],
},
'license': 'OPL-1',
'installable': True,
'application': False,
'auto_install': False,
'price': 249.0,
'currency': 'USD',
}
Convention de numérotation des versions : {odoo_version}.{major}.{minor}.{patch}. Commencez toujours par 19.0.1.0.0 pour les nouveaux modules.
Définir des modèles
Les modèles sont le cœur de tout module Odoo. Ils définissent la structure des données et la logique métier.
# models/service_request.py
from odoo import api, fields, models
from odoo.exceptions import ValidationError, UserError
class ServiceRequest(models.Model):
_name = 'my.service.request'
_description = 'Service Request'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'date_request desc, name'
_rec_name = 'name'
name = fields.Char(
string='Reference',
required=True,
copy=False,
readonly=True,
default=lambda self: self.env['ir.sequence'].next_by_code('my.service.request')
)
state = fields.Selection([
('draft', 'Draft'),
('submitted', 'Submitted'),
('in_progress', 'In Progress'),
('done', 'Completed'),
('cancelled', 'Cancelled'),
], string='Status', default='draft', tracking=True)
partner_id = fields.Many2one(
'res.partner', string='Customer',
required=True, tracking=True,
domain=[('customer_rank', '>', 0)]
)
user_id = fields.Many2one(
'res.users', string='Assigned To',
default=lambda self: self.env.user
)
date_request = fields.Datetime(
string='Request Date',
default=fields.Datetime.now,
required=True
)
date_deadline = fields.Date(string='Deadline')
description = fields.Html(string='Description')
priority = fields.Selection([
('0', 'Normal'),
('1', 'Low'),
('2', 'High'),
('3', 'Urgent'),
], string='Priority', default='0')
tag_ids = fields.Many2many(
'my.service.tag', string='Tags'
)
line_ids = fields.One2many(
'my.service.request.line', 'request_id',
string='Service Lines'
)
amount_total = fields.Float(
string='Total Amount',
compute='_compute_amount_total',
store=True
)
company_id = fields.Many2one(
'res.company', string='Company',
required=True,
default=lambda self: self.env.company
)
@api.depends('line_ids.subtotal')
def _compute_amount_total(self):
for request in self:
request.amount_total = sum(request.line_ids.mapped('subtotal'))
@api.constrains('date_deadline', 'date_request')
def _check_deadline(self):
for record in self:
if record.date_deadline and record.date_request:
if record.date_deadline < record.date_request.date():
raise ValidationError("Deadline cannot be before the request date.")
@api.onchange('partner_id')
def _onchange_partner_id(self):
if self.partner_id:
self.user_id = self.partner_id.user_id or self.env.user
def action_submit(self):
for record in self:
if not record.line_ids:
raise UserError("Cannot submit a request without service lines.")
record.state = 'submitted'
record.message_post(body="Service request submitted for processing.")
def action_start_progress(self):
self.write({'state': 'in_progress'})
def action_mark_done(self):
self.write({'state': 'done'})
def action_cancel(self):
for record in self:
if record.state == 'done':
raise UserError("Cannot cancel a completed request.")
record.state = 'cancelled'
@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(
'my.service.request'
) or 'New'
return super().create(vals_list)
Modèle de ligne de demande de service :
class ServiceRequestLine(models.Model):
_name = 'my.service.request.line'
_description = 'Service Request Line'
request_id = fields.Many2one(
'my.service.request', string='Request',
required=True, ondelete='cascade'
)
product_id = fields.Many2one(
'product.product', string='Service',
required=True,
domain=[('type', '=', 'service')]
)
description = fields.Text(string='Description')
quantity = fields.Float(string='Quantity', default=1.0)
price_unit = fields.Float(string='Unit Price')
subtotal = fields.Float(
string='Subtotal',
compute='_compute_subtotal',
store=True
)
@api.depends('quantity', 'price_unit')
def _compute_subtotal(self):
for line in self:
line.subtotal = line.quantity * line.price_unit
@api.onchange('product_id')
def _onchange_product_id(self):
if self.product_id:
self.price_unit = self.product_id.lst_price
self.description = self.product_id.description_sale
Création de vues
Les vues définissent la manière dont les enregistrements sont affichés dans l'interface utilisateur. Odoo utilise XML pour décrire les formulaires, les listes, les tableaux Kanban, etc.
<!-- views/service_request_views.xml -->
<odoo>
<!-- Form View -->
<record id="view_service_request_form" model="ir.ui.view">
<field name="name">my.service.request.form</field>
<field name="model">my.service.request</field>
<field name="arch" type="xml">
<form string="Service Request">
<header>
<button name="action_submit" string="Submit"
type="object" class="oe_highlight"
invisible="state != 'draft'"/>
<button name="action_start_progress" string="Start"
type="object" class="oe_highlight"
invisible="state != 'submitted'"/>
<button name="action_mark_done" string="Mark Done"
type="object" class="oe_highlight"
invisible="state != 'in_progress'"/>
<button name="action_cancel" string="Cancel"
type="object"
invisible="state in ['done', 'cancelled']"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,submitted,in_progress,done"/>
</header>
<sheet>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<group>
<group>
<field name="partner_id"
options="{'no_create': True}"/>
<field name="user_id"/>
<field name="priority" widget="priority"/>
</group>
<group>
<field name="date_request"/>
<field name="date_deadline"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<field name="tag_ids" widget="many2many_tags"/>
<notebook>
<page string="Service Lines">
<field name="line_ids">
<tree editable="bottom">
<field name="product_id"/>
<field name="description"/>
<field name="quantity"/>
<field name="price_unit"/>
<field name="subtotal" readonly="1"/>
</tree>
</field>
<group class="oe_subtotal_footer">
<field name="amount_total"
widget="monetary"
class="oe_subtotal_footer_separator"/>
</group>
</page>
<page string="Description">
<field name="description" widget="html"
placeholder="Detailed description..."/>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- List View -->
<record id="view_service_request_tree" model="ir.ui.view">
<field name="name">my.service.request.list</field>
<field name="model">my.service.request</field>
<field name="arch" type="xml">
<tree string="Service Requests" decoration-danger="state=='cancelled'"
decoration-success="state=='done'">
<field name="name"/>
<field name="partner_id"/>
<field name="user_id" optional="show"/>
<field name="priority" widget="priority"/>
<field name="date_request"/>
<field name="date_deadline" optional="show"/>
<field name="amount_total" sum="Total"/>
<field name="state" widget="badge"
decoration-info="state=='draft'"
decoration-warning="state=='submitted'"
decoration-primary="state=='in_progress'"
decoration-success="state=='done'"
decoration-danger="state=='cancelled'"/>
</tree>
</field>
</record>
<!-- Search View -->
<record id="view_service_request_search" model="ir.ui.view">
<field name="name">my.service.request.search</field>
<field name="model">my.service.request</field>
<field name="arch" type="xml">
<search>
<field name="name" string="Reference"/>
<field name="partner_id"/>
<field name="user_id"/>
<filter string="My Requests" name="my_requests"
domain="[('user_id', '=', uid)]"/>
<filter string="In Progress" name="in_progress"
domain="[('state', '=', 'in_progress')]"/>
<filter string="Urgent" name="urgent"
domain="[('priority', '=', '3')]"/>
<separator/>
<filter string="This Month" name="this_month"
domain="[('date_request', '>=',
(context_today() - relativedelta(day=1)).strftime('%Y-%m-%d'))]"/>
<group expand="0" string="Group By">
<filter string="Customer" name="group_partner"
context="{'group_by': 'partner_id'}"/>
<filter string="Status" name="group_state"
context="{'group_by': 'state'}"/>
<filter string="Assigned To" name="group_user"
context="{'group_by': 'user_id'}"/>
</group>
</search>
</field>
</record>
<!-- Action -->
<record id="action_service_request" model="ir.actions.act_window">
<field name="name">Service Requests</field>
<field name="res_model">my.service.request</field>
<field name="view_mode">list,form,kanban</field>
<field name="search_view_id" ref="view_service_request_search"/>
<field name="context">{'search_default_in_progress': 1}</field>
</record>
</odoo>
Configuration de la sécurité
La sécurité est obligatoire pour tout module de production.
Liste de contrôle d'accès (security/ir.model.access.csv) :
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_service_request_user,service.request.user,model_my_service_request,base.group_user,1,1,1,0
access_service_request_manager,service.request.manager,model_my_service_request,base.group_system,1,1,1,1
access_service_request_line_user,service.request.line.user,model_my_service_request_line,base.group_user,1,1,1,1
Règles d'enregistrement (security/my_module_security.xml) :
<odoo>
<!-- Users can only see their own requests unless they're managers -->
<record id="rule_service_request_own" model="ir.rule">
<field name="name">Service Request: Own Records</field>
<field name="model_id" ref="model_my_service_request"/>
<field name="domain_force">
[('user_id', '=', user.id)]
</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
</odoo>
Assistants (TransientModel)
Les assistants sont des formulaires temporaires pour des actions guidées en plusieurs étapes.
# wizard/service_request_wizard.py
from odoo import api, fields, models
class ServiceRequestBulkAssign(models.TransientModel):
_name = 'my.service.request.bulk.assign'
_description = 'Bulk Assign Service Requests'
user_id = fields.Many2one(
'res.users', string='Assign To', required=True
)
request_ids = fields.Many2many(
'my.service.request', string='Requests',
default=lambda self: self.env.context.get('active_ids', [])
)
note = fields.Text(string='Note')
def action_assign(self):
self.request_ids.write({'user_id': self.user_id.id})
if self.note:
for request in self.request_ids:
request.message_post(body=self.note)
return {'type': 'ir.actions.act_window_close'}
Actions et séquences automatisées
Séquence de numérotation automatique :
<record id="seq_service_request" model="ir.sequence">
<field name="name">Service Request</field>
<field name="code">my.service.request</field>
<field name="prefix">SRQ/%(year)s/</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
Action planifiée (tâche cron) :
# In the model
def _cron_remind_overdue_requests(self):
overdue = self.search([
('state', 'in', ['submitted', 'in_progress']),
('date_deadline', '<', fields.Date.today()),
])
for request in overdue:
request.activity_schedule(
'mail.mail_activity_data_warning',
summary='Overdue Service Request',
user_id=request.user_id.id
)
<record id="ir_cron_remind_overdue" model="ir.cron">
<field name="name">Remind Overdue Service Requests</field>
<field name="model_id" ref="model_my_service_request"/>
<field name="state">code</field>
<field name="code">model._cron_remind_overdue_requests()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="active">True</field>
</record>
Questions fréquemment posées
Quelle est la différence entre models.Model, models.TransientModel et models.AbstractModel ?
models.Model crée une table permanente dans la base de données. models.TransientModel crée une table temporaire effacée périodiquement (utilisée pour les assistants). models.AbstractModel ne crée aucune table — c'est un mixin dont d'autres modèles peuvent hériter pour obtenir des méthodes et des champs sans créer de table séparée.
Comment étendre un modèle Odoo existant sans modifier le code principal ?
Utilisez _inherit avec le nom du modèle existant et omettez _name. Cela ajoute vos champs et méthodes au modèle existant : class SaleOrder(models.Model): _inherit = 'sale.order'. Pour créer un nouveau modèle qui copie le comportement d'un autre comme point de départ, utilisez à la fois _name (nouveau nom) et _inherit (modèle source).
Comment dois-je gérer les migrations lorsque le modèle de données de mon module change ?
Créez un script de migration dans my_module/migrations/{version}/pre-migrate.py ou post-migrate.py. Ces scripts s'exécutent automatiquement lors de la mise à jour du module. Pour renommer les colonnes, utilisez les assistants openupgradelib. Testez toujours les migrations sur une copie de la base de données de production avant de postuler en production.
Puis-je remplacer les vues Odoo existantes sans modifier les fichiers XML principaux ?
Oui. Utilisez inherit_id pour référencer la vue que vous souhaitez étendre, puis utilisez les expressions xpath pour localiser l'élément et l'attribut position (avant, après, à l'intérieur, remplacer, attributs) pour spécifier la modification. Cela maintient vos modifications isolées et sécurisées pour la mise à niveau.
Comment rendre un champ spécifique à une entreprise dans un environnement multi-entreprises ?
Utilisez company_dependent=True sur la définition du champ : my_field = fields.Char(company_dependent=True). Cela stocke une valeur distincte par société, de sorte que la société A et la société B peuvent avoir des valeurs différentes pour le même enregistrement. Ceci est utilisé pour les listes de prix, les comptes fiscaux et d’autres configurations spécifiques à l’entreprise.
Quelle est la bonne façon de consigner les messages et de déboguer pendant le développement ?
Utilisez le module logging de Python : import logging; _logger = logging.getLogger(__name__). Utilisez _logger.info(), _logger.warning(), _logger.error() pour différents niveaux de gravité. N'utilisez jamais d'instructions print() dans le code de production. En développement, exécutez Odoo avec --log-level=debug pour voir toutes les sorties de débogage.
Prochaines étapes
Construire un module Odoo prêt pour la production nécessite une connaissance approfondie du framework, des considérations en matière de performances de PostgreSQL, des modèles sécurisés pour la mise à niveau et des tests approfondis. Les modules destinés au marché Odoo subissent une validation supplémentaire en matière de sécurité, de performances et de qualité du code.
ECOSIRE développe des modules Odoo 19 Enterprise personnalisés pour des besoins commerciaux spécifiques – des flux de travail industriels spécialisés aux modules de connecteurs de marché. Notre équipe de développement suit les directives de codage officielles d'Odoo, inclut des tests unitaires complets et fournit des modules avec une documentation complète.
Commandez un module Odoo personnalisé auprès d'ECOSIRE →
Partagez vos exigences et nous évaluerons l'effort de développement, fournirons un calendrier et fournirons un module qui s'intègre parfaitement à votre installation Odoo 19 Enterprise.
Rédigé par
ECOSIRE Research and Development Team
Création de produits numériques de niveau entreprise chez ECOSIRE. Partage d'analyses sur les intégrations Odoo, l'automatisation e-commerce et les solutions d'entreprise propulsées par l'IA.
Articles connexes
Odoo Accounting vs QuickBooks: Detailed Comparison 2026
In-depth 2026 comparison of Odoo Accounting vs QuickBooks covering features, pricing, integrations, scalability, and which platform fits your business needs.
Case Study: eCommerce Migration to Shopify with Odoo Backend
How a fashion retailer migrated from WooCommerce to Shopify and connected it to Odoo ERP, cutting order fulfillment time by 71% and growing revenue 43%.
Case Study: Manufacturing ERP Implementation with Odoo 19
How a Pakistani auto-parts manufacturer cut order processing time by 68% and reduced inventory variance to under 2% with ECOSIRE's Odoo 19 implementation.