Module 1 — ORM, Modèles & Champs

Le coeur d'Odoo : comprendre comment déclarer un modèle, ses champs, ses contraintes et manipuler des recordsets.

6h Concepts fondamentaux

🎯 Objectifs

1. Les 3 types de modèles

TypePersiste en DB ?Cas d'usage
models.Model✅ Oui (table créée)Données métier (clients, commandes, livres…)
models.TransientModel⚠️ Temporaire (purge auto)Wizards (assistants modaux)
models.AbstractModel❌ NonMixin de méthodes réutilisables
models/library_book.py
from odoo import models, fields, api

class LibraryBook(models.Model):
    _name = 'library.book'
    _description = 'Livre de la bibliothèque'
    _order = 'name asc'
    _rec_name = 'title'   # champ utilisé comme display_name par défaut

    title = fields.Char(string='Titre', required=True, tracking=True)
    isbn = fields.Char(string='ISBN')
    active = fields.Boolean(default=True)
📌
Convention de nommage _name

Toujours en minuscules, séparé par points : module.entity. Exemples : sale.order, account.move.line, library.book.author. La table SQL générée remplace les points par des underscores : library_book.

2. Les champs essentiels

Champs simples

name = fields.Char(string='Nom', size=64, required=True)
description = fields.Text()                            # texte multi-ligne
note = fields.Html()                                   # éditeur riche
quantity = fields.Integer(default=0)
price = fields.Float(digits=(10, 2))               # 10 chiffres dont 2 décimales
amount = fields.Monetary(currency_field='currency_id') # nécessite un Many2one currency
is_published = fields.Boolean(default=False)
publish_date = fields.Date()
last_update = fields.Datetime(default=fields.Datetime.now)
photo = fields.Binary(attachment=True)               # stocké comme attachment

Selection

state = fields.Selection([
    ('draft', 'Brouillon'),
    ('confirmed', 'Confirmé'),
    ('done', 'Terminé'),
    ('cancel', 'Annulé'),
], string='État', default='draft', tracking=True)

Relations — les 3 piliers d'Odoo

# Many2one : N livres pointent vers 1 auteur
author_id = fields.Many2one(
    'res.partner',
    string='Auteur',
    domain=[('is_company', '=', False)],   # filtre les choix
    ondelete='restrict',                       # cascade, restrict, set null
    tracking=True,
)

# One2many : 1 auteur a N livres (inverse de Many2one)
book_ids = fields.One2many(
    'library.book',
    'author_id',            # nom du champ Many2one côté library.book
    string='Livres',
)

# Many2many : N livres ont N tags
tag_ids = fields.Many2many(
    'library.tag',
    'library_book_tag_rel',  # nom de la table de jonction (optionnel)
    'book_id',                # colonne côté ce modèle
    'tag_id',                 # colonne côté autre modèle
    string='Tags',
)
⚠️
Convention nommage Odoo

Tous les champs relationnels finissent par _id (Many2one) ou _ids (One2many/Many2many). Cette convention est utilisée par les vues, les rapports et certaines méthodes automatiques. La respecter est NON-NÉGOCIABLE.

Champs calculés (computed)

# Calculé à la volée, NON stocké
display_name = fields.Char(compute='_compute_display_name')

# Calculé ET stocké (indexable, searchable, plus rapide en liste)
total = fields.Float(
    compute='_compute_total',
    store=True,
    digits=(10, 2),
)

@api.depends('title', 'author_id.name')
def _compute_display_name(self):
    for book in self:
        book.display_name = f"{book.title} — {book.author_id.name or '?'}"

@api.depends('line_ids.subtotal')
def _compute_total(self):
    for rec in self:
        rec.total = sum(rec.line_ids.mapped('subtotal'))
💡
Stored ou non ?

Stored = True si vous voulez : trier par ce champ, le mettre dans un domaine de recherche, l'afficher dans une vue liste sur 1000+ lignes. Stored = False sinon (économise de l'espace DB et évite des recalculs en cascade).

Champs related (raccourcis)

# Raccourci vers un champ d'un autre modèle via une relation
author_country = fields.Char(
    related='author_id.country_id.name',
    string='Pays auteur',
    store=True,   # optionnel : stocke la valeur pour faciliter le tri
)

3. Les décorateurs API

DécorateurQuandUsage
@api.dependscomputeListe les champs déclencheurs du recalcul
@api.constrainsvalidation PythonLève ValidationError si invalide (côté serveur)
@api.onchangeUIRéagit à un changement dans le formulaire (côté client, non-persisté)
@api.modelméthode "static"Méthode sans recordset, opère sur le modèle (ex: create)
@api.returnscompat. legacyForce le type retour (rare en code moderne)
from odoo import _
from odoo.exceptions import ValidationError, UserError

@api.constrains('isbn')
def _check_isbn_unique(self):
    for book in self:
        if book.isbn:
            duplicates = self.search_count([
                ('isbn', '=', book.isbn),
                ('id', '!=', book.id),
            ])
            if duplicates:
                raise ValidationError(_("L'ISBN %s existe déjà.") % book.isbn)

@api.onchange('author_id')
def _onchange_author_id(self):
    # Réagit en live dans le form, AVANT le save
    if self.author_id and not self.publisher_id:
        self.publisher_id = self.author_id.default_publisher_id
Ne JAMAIS faire de logique métier dans onchange

onchange ne se déclenche que dans l'UI. Si un record est créé via API, import CSV ou autre, l'onchange n'est PAS appelé. Toute logique critique doit être dans compute, constrains ou des méthodes appelées depuis create/write.

4. Recordsets — la magie de l'ORM

En Odoo, presque toutes les méthodes opèrent sur des recordsets (collections de records). Comprendre ça change la vie.

# SEARCH = retourne un recordset
books = env['library.book'].search([('state', '=', 'available')])
print(len(books))                    # nombre
print(books.ids)                    # [12, 13, 45, ...]

# BROWSE = construit un recordset à partir d'ids (PAS de requête SQL immédiate)
book = env['library.book'].browse(42)
print(book.title)                  # SQL exécutée ici, lazy

# FILTERED = filtre Python sans SQL (sur un recordset déjà en mémoire)
fr_books = books.filtered(lambda b: b.language == 'fr')

# MAPPED = projection / extraction d'un champ
titles = books.mapped('title')                # liste de strings
authors = books.mapped('author_id')            # recordset d'auteurs (dédupliqué)

# SORTED = tri Python
sorted_books = books.sorted('title')
sorted_books = books.sorted(lambda b: b.publish_date, reverse=True)

# Opérations sur recordsets
all_books = books_a | books_b   # union
common = books_a & books_b      # intersection
diff = books_a - books_b        # différence

5. Search domains

Les domains sont la grammaire Odoo pour exprimer une condition de recherche.

# Simple : liste de tuples (field, operator, value)
env['library.book'].search([
    ('state', '=', 'available'),
    ('price', '>', 10),
])                              # AND implicite entre les conditions

# Opérateurs : =, !=, >, >=, <, <=, in, not in, like, ilike, =like, child_of

# OR explicite : préfixer avec '|'
env['library.book'].search([
    '|',
    ('state', '=', 'available'),
    ('state', '=', 'reserved'),
])

# Recherche dans une relation : utiliser le point
env['library.book'].search([
    ('author_id.country_id.code', '=', 'FR'),
])

6. Override des méthodes CRUD

Toute la magie d'Odoo : create, write, unlink sont overridables. Cela permet d'ajouter de la logique métier au moment où un record est créé/modifié/supprimé.

@api.model_create_multi
def create(self, vals_list):
    # vals_list est une LISTE de dicts (depuis Odoo 17)
    for vals in vals_list:
        if not vals.get('reference'):
            vals['reference'] = self.env['ir.sequence'].next_by_code('library.book')
    books = super().create(vals_list)
    # actions post-création
    for book in books:
        book._notify_librarian()
    return books

def write(self, vals):
    # Ici, self est le recordset à modifier
    if 'state' in vals and vals['state'] == 'archived':
        if self.filtered(lambda b: b.reservation_ids):
            raise UserError(_("Impossible d'archiver un livre avec des réservations actives."))
    return super().write(vals)

def unlink(self):
    if any(book.reservation_count for book in self):
        raise UserError(_("Impossible de supprimer un livre avec un historique de prêts."))
    return super().unlink()
📌
@api.model_create_multi en Odoo 19

Depuis Odoo 17, create reçoit une liste de vals (batch). Toujours utiliser le décorateur @api.model_create_multi et itérer sur vals_list. L'ancien create(vals) single-dict est déprécié.

7. Mécanique d'héritage

Trois types d'héritage en Odoo — à ne pas confondre :

Inheritance "classique" (Python)

class ResPartner(models.Model):
    _inherit = 'res.partner'   # étend le modèle existant

    library_card_id = fields.Char(string='N° carte bibliothèque')
    book_ids = fields.One2many('library.book', 'author_id')

Inheritance "delegation" (composition)

class LibraryMember(models.Model):
    _name = 'library.member'
    _inherits = {'res.partner': 'partner_id'}   # minuscule "s"

    partner_id = fields.Many2one('res.partner', required=True, ondelete='cascade')
    membership_date = fields.Date()
    # Tous les champs de res.partner sont accessibles directement : member.name, member.email…

Mixin abstrait

class MailThread(models.AbstractModel):
    _name = 'mail.thread'
    # méthodes réutilisables, pas de table

class LibraryBook(models.Model):
    _name = 'library.book'
    _inherit = ['mail.thread']   # liste = mixin

8. Cheatsheet env

self.env.cr            # curseur DB
self.env.user          # utilisateur courant (res.users)
self.env.company       # société courante (res.company)
self.env.companies     # sociétés accessibles
self.env.context       # dict de contexte
self.env.lang          # langue courante
self.env.ref('module.xml_id')   # récupère un record par son XML ID
self.env['res.partner']      # accès au modèle

# Changer de contexte (utile pour bypass certaines règles)
partners = self.env['res.partner'].with_context(active_test=False).search([])
# active_test=False inclut les records archivés

# Changer d'utilisateur
sudo_partners = self.env['res.partner'].sudo().search([])
# sudo() bypasse les record rules — à utiliser avec précaution !
Le .sudo() n'est pas magique

.sudo() exécute en tant que super-utilisateur (bypass des record rules ET des access rights). Utilisez-le uniquement quand vous savez exactement ce que vous faites, typiquement pour des hooks système. JAMAIS dans une méthode déclenchée par un utilisateur sans contrôle préalable.

🏋️ Exercice de validation

Mission

Créer un modèle library.book avec :

  1. Les champs : title (Char, required), isbn (Char, unique), publish_date (Date), author_id (Many2one res.partner), state (Selection draft/available/borrowed/lost), tag_ids (Many2many).
  2. Un champ computed stored age_days qui calcule le nombre de jours depuis publication.
  3. Une contrainte Python qui vérifie que l'ISBN fait exactement 13 caractères s'il est rempli.
  4. Un override de create qui définit l'auteur à env.user.partner_id si non fourni.
  5. Une méthode action_borrow() qui passe le record en borrowed et lève une UserError si déjà borrowed.

Tester dans le shell Odoo : créer 3 livres, en emprunter un, vérifier les contraintes.

🔗 Ressources

📋 Quiz de validation

Module 0 — Setup Module 2 — Vues & OWL