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
cd /path/to/your/odoo/addons
./odoo-bin scaffold gestion_projets .
# Edit __manifest__.py to activate the module afterThe 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
{
'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
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
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)
<?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
Security: Access Rights 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,1CSV 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
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
<?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 in Projet view.
Add Main Menu
<?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