Se rendre au contenu

Hiérarchie de modèles Odoo 19 : parent_id, child_ids, _parent_store

Bloc 3 · Framework ORM — Article 7/8 Hiérarchie de modèles Odoo 19 Structurer tes données en arborescence avec parent_id , child_ids , _parent_store et…
26 avril 2026 par
Hiérarchie de modèles Odoo 19 : parent_id, child_ids, _parent_store
B.Mustapha

Bloc 3 · Framework ORM — Article 7/8

Hiérarchie de modèles Odoo 19

Structurer tes données en arborescence avec parent_id, child_ids, _parent_store et parent_path — appliqué aux catégories de tickets helpdesk.

~14 minutes de lecture

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.

Prérequis

  • Module odooskills_helpdesk v19.0.1.5.0 de T13 installé.
  • Connaissance des relations Many2one / One2many (voir T11).
  • Compréhension des champs calculés et contraintes (voir T12).

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
Support parent_id = NULL · path = "1/" Matériel parent_id = 1 · path = "1/2/" Logiciel parent_id = 1 · path = "1/3/" Réseau parent_id = 1 · path = "1/4/" Imprimante path = "1/2/5/" PC portable path = "1/2/6/" Office 365 path = "1/3/7/" Wi-Fi path = "1/4/8/" parent_path encode le chemin entier — sélectionner tous les descendants = un LIKE "1/2/%"

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.

Pourquoi 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êteSans _parent_storeAvec _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
Piège classique : oublier la colonne 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.

Bon à savoir : la méthode _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

T11 — Relations entre modèles

Many2one, One2many, Many2many

T12 — Contraintes et champs calculés

@api.depends, models.Constraint

T13 — Héritage des modèles

_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.

Télécharger le guide technique Odoo 19 (PDF gratuit)
Héritage des modèles Odoo 19 : _inherit, _inherits, AbstractModel
Bloc 3 · Framework ORM — Article 6/8 Héritage des modèles Odoo 19 Les trois formes d'héritage de l'ORM Odoo — _inherit pour étendre, _inherit + _name pour…