Ce que tu vas apprendre
Auto-relation
Un Many2one pointant vers son propre modèle — fondation de toute hiérarchie.
_parent_store
Index MPTT natif Odoo pour requêtes de descendance en une seule query.
Anti-récursion
Empêcher qu'un nœud ne devienne son propre ancêtre avec _check_recursion().
complete_name
Champ calculé récursif affichant le chemin complet Support / Matériel / Imprimante.
1. Pourquoi modéliser une hiérarchie ?
Jusqu'à T13, une catégorie de ticket est un enregistrement plat : Matériel,
Logiciel, Réseau. Mais dans un support réel, tu as besoin de
sous-catégories : Matériel > Imprimante > Bourrage papier.
Deux approches :
- Table séparée par niveau (category, subcategory, subsubcategory) — rigide, limité en profondeur, duplication de code.
- Auto-relation avec arbre — un seul modèle, profondeur illimitée, c'est le pattern Odoo.
Odoo utilise ce pattern partout : res.partner (parent company),
account.account (plan comptable hiérarchique),
product.category, hr.department, stock.location…
La mécanique est toujours la même : parent_id + child_ids + index MPTT.
Structure cible — catégories helpdesk
2. L'auto-relation Many2one / One2many
Première étape, absolument minimale : un champ parent_id qui pointe vers le
même modèle, et un child_ids qui expose les enfants directs.
from odoo import models, fields
class HelpdeskTicketCategory(models.Model):
_name = 'helpdesk.ticket.category'
_description = 'Catégorie de ticket helpdesk'
name = fields.Char(string='Nom', required=True)
parent_id = fields.Many2one(
'helpdesk.ticket.category',
string='Catégorie parente',
ondelete='cascade',
index=True,
)
child_ids = fields.One2many(
'helpdesk.ticket.category',
'parent_id',
string='Sous-catégories',
)
À ce stade, tu peux déjà créer des arbres — mais chaque requête « tous les descendants de X »
nécessite une récursion Python ou un CTE SQL. Pour un plan comptable avec
10 000 comptes sur 6 niveaux, c'est le désastre. D'où _parent_store.
ondelete='cascade' ? Supprimer une catégorie parente doit
supprimer la sous-arborescence entière. Alternative : restrict si tu veux forcer
l'utilisateur à vider manuellement.
3. _parent_store — l'index MPTT d'Odoo
MPTT (Modified Preorder Tree Traversal) est un algorithme classique pour stocker
des arbres en base relationnelle. Odoo en offre une variante simplifiée à base de chemin
matérialisé : chaque nœud stocke sa position sous forme de chaîne
"1/2/5/" dans la colonne parent_path.
L'activation tient en trois attributs de classe :
class HelpdeskTicketCategory(models.Model):
_name = 'helpdesk.ticket.category'
_parent_name = 'parent_id' # par défaut, mais explicite vaut mieux
_parent_store = True # ← active l'index MPTT
parent_id = fields.Many2one('helpdesk.ticket.category', ondelete='cascade', index=True)
parent_path = fields.Char(index=True) # ← OBLIGATOIRE avec _parent_store
Odoo maintient automatiquement la colonne parent_path à chaque
create / write qui modifie parent_id. Résultat :
| Requête | Sans _parent_store | Avec _parent_store |
|---|---|---|
| Tous les descendants de la catégorie 2 | Récursion Python (N queries) | [('parent_path','=like','1/2/%')] — 1 query |
Opérateurs child_of / parent_of |
Non supportés | Natifs — [('id','child_of',2)] |
| Affichage d'un arbre dans l'interface | Lent sur gros volumes | Tri optimisé par parent_path |
parent_path. Odoo déclenche
alors une erreur au chargement du modèle : « Model has _parent_store but no parent_path field ».
Toujours déclarer parent_path = fields.Char(index=True).
4. complete_name — le chemin lisible
Quand l'utilisateur sélectionne Imprimante dans un menu déroulant, il ne sait pas si c'est sous Matériel ou sous Fournitures. Solution : un champ calculé qui affiche Support / Matériel / Imprimante.
complete_name = fields.Char(
string='Nom complet',
compute='_compute_complete_name',
store=True,
recursive=True, # ← clé : autorise la dépendance récursive
)
@api.depends('name', 'parent_id.complete_name')
def _compute_complete_name(self):
for category in self:
if category.parent_id:
category.complete_name = f"{category.parent_id.complete_name} / {category.name}"
else:
category.complete_name = category.name
Deux points critiques :
recursive=True— sans ce flag, Odoo refuse une dépendance@api.depends('parent_id.complete_name')sur le même modèle (détection de cycle). Le flag dit à l'ORM : « je sais ce que je fais, propage en cascade ».store=True— sinon le calcul se refait à chaque lecture, or tu l'utilises dans_rec_name, donc partout. On stocke.
Tu peux alors remplacer _rec_name par complete_name
pour que les Many2one affichent le chemin complet :
_rec_name = 'complete_name'
5. Protection anti-récursion
Rien n'empêche un utilisateur malicieux (ou maladroit) de définir Matériel comme
parent de Support, qui est déjà son ancêtre. Tu obtiens un cycle,
parent_path part en boucle infinie, le serveur tombe.
La parade est fournie clé-en-main par l'ORM : _check_recursion().
from odoo import api, models
from odoo.exceptions import ValidationError
class HelpdeskTicketCategory(models.Model):
_name = 'helpdesk.ticket.category'
_parent_store = True
@api.constrains('parent_id')
def _check_category_recursion(self):
if not self._check_recursion():
raise ValidationError(
"Vous ne pouvez pas créer de catégories récursives."
)
self._check_recursion() renvoie True si aucun enregistrement
du recordset n'est son propre ancêtre, False sinon. À déclencher sur
@api.constrains('parent_id') pour couvrir create et write.
_check_recursion() est définie sur
models.Model, disponible sur tous les modèles — mais elle s'appuie sur
_parent_name. Si tu l'utilises sur un modèle qui n'a pas de
parent_id, elle lève une erreur.
6. Unicité du nom par parent
En T12, on avait posé UNIQUE (name) sur la catégorie — une seule
Matériel dans toute la base. Avec une hiérarchie, ce n'est plus ce qu'on veut :
tu peux avoir Réseau / Interne et Matériel / Interne.
On relâche la contrainte au niveau même parent :
_unique_name_parent = models.Constraint(
'UNIQUE (name, parent_id)',
"Le nom de la catégorie doit être unique au sein d'un même parent.",
)
PostgreSQL considère NULL comme distinct dans un index unique — donc plusieurs
catégories racines peuvent porter le même nom ? Oui, par défaut. Si tu veux aussi forcer
l'unicité à la racine, ajoute une contrainte partielle ou une @api.constrains
complémentaire.
Récapitulatif — fichier complet T14
models/helpdesk_ticket_category.py
from odoo import models, fields, api
from odoo.exceptions import ValidationError
class HelpdeskTicketCategory(models.Model):
"""Catégorie de ticket — modèle hiérarchique (arbre parent/enfants)."""
_name = 'helpdesk.ticket.category'
_description = 'Catégorie de ticket helpdesk'
_order = 'sequence, name'
_rec_name = 'name'
_parent_name = 'parent_id'
_parent_store = True
_unique_name_parent = models.Constraint(
'UNIQUE (name, parent_id)',
"Le nom de la catégorie doit être unique au sein d'un même parent.",
)
name = fields.Char(string='Nom', required=True)
sequence = fields.Integer(string='Séquence', default=10)
color = fields.Integer(string='Couleur')
parent_id = fields.Many2one(
'helpdesk.ticket.category',
string='Catégorie parente',
ondelete='cascade',
index=True,
)
parent_path = fields.Char(index=True)
child_ids = fields.One2many(
'helpdesk.ticket.category',
'parent_id',
string='Sous-catégories',
)
complete_name = fields.Char(
string='Nom complet',
compute='_compute_complete_name',
store=True,
recursive=True,
)
@api.depends('name', 'parent_id.complete_name')
def _compute_complete_name(self):
for category in self:
if category.parent_id:
category.complete_name = f"{category.parent_id.complete_name} / {category.name}"
else:
category.complete_name = category.name
@api.constrains('parent_id')
def _check_category_recursion(self):
if not self._check_recursion():
raise ValidationError(
"Vous ne pouvez pas créer de catégories récursives."
)
Upgrade du module
./odoo-bin -c config/odoo.conf -u odooskills_helpdesk -d ta_base --stop-after-init
# Vérifier les nouvelles colonnes
psql -d ta_base -c "\d helpdesk_ticket_category"
Tu dois voir trois colonnes nouvelles dans helpdesk_ticket_category :
parent_id, parent_path et complete_name, plus un
index btree sur parent_path. Odoo affiche à l'upgrade une ligne
« Computing parent_path for table helpdesk_ticket_category… » —
c'est l'initialisation des chemins pour les lignes existantes.
Requêtes ORM qui tirent parti de la hiérarchie
# Tous les descendants (directs + indirects) d'une catégorie
Category = self.env['helpdesk.ticket.category']
descendants = Category.search([('id', 'child_of', materiel.id)])
# Tous les ancêtres d'une catégorie
ancestors = Category.search([('id', 'parent_of', imprimante.id)])
# Seulement les racines
roots = Category.search([('parent_id', '=', False)])
# Tickets ouverts pour toute la branche "Matériel"
self.env['helpdesk.ticket'].search([
('category_id', 'child_of', materiel.id),
('state', '=', 'open'),
])
Voir aussi dans cette série
Many2one, One2many, Many2many
T12 — Contraintes et champs calculés
@api.depends, models.Constraint
_inherit, _inherits, mixins
Prochain article — T15
Les méthodes de modèle :
create, write, unlink, méthodes métier,
décorateurs @api.model et @api.model_create_multi,
surcharges propres dans un module.