Se rendre au contenu

Contraintes et champs calculés Odoo 19 : @api.depends, models.Constraint

Bloc 3 · Framework ORM — Article 5/8 Contraintes et champs calculés Odoo 19 Comment créer des champs dont la valeur se calcule automatiquement et poser des…
26 avril 2026 par
Contraintes et champs calculés Odoo 19 : @api.depends, models.Constraint
B.Mustapha

Bloc 3 · Framework ORM — Article 5/8

Contraintes et champs calculés Odoo 19

Comment créer des champs dont la valeur se calcule automatiquement et poser des règles d'intégrité — côté Python avec @api.constrains et côté SQL avec models.Constraint.

~16 minutes de lecture

Ce que tu vas apprendre

@api.depends

Déclarer les dépendances d'un champ calculé et choisir store=True/False.

@api.onchange

Réagir aux changements dans le formulaire avant sauvegarde.

@api.constrains

Valider des règles métier complexes en Python — message d'erreur propre.

models.Constraint

Contraintes SQL (UNIQUE, CHECK) — syntaxe v19, plus efficaces que Python.

Prérequis
Le module fil rouge — Cet article fait passer odooskills_helpdesk de 19.0.1.3.0 à 19.0.1.4.0. On ajoute deux champs calculés (duration, is_overdue) et une contrainte Python sur les dates.

Computed fields & contraintes — la vue d'ensemble

Champs calculés (Computed Fields) store=False (défaut) • Calculé à chaque lecture (pas de colonne PG) • Non filtrable en search() / domain ⚠ Non stocké = pas cherchable store=True • Colonne réelle en base PostgreSQL • Recalculé automatiquement si dépendance change ✓ Filtrable, triable, exportable @api.onchange • Réagit aux changements dans le formulaire • Exécuté côté client (pas de sauvegarde) ⚠ N'est PAS appelé lors d'un write() Python Contraintes d'intégrité @api.constrains (Python) • Validation métier complexe • Accès à self, self.env, calculs Python • Lève ValidationError avec message personnalisé ⚠ Appelé sur write() ET create() models.Constraint (SQL) • UNIQUE ou CHECK au niveau PostgreSQL • Plus performant que Python pour unicité ✓ Syntaxe v19 — remplace _sql_constraints ⚠ Message moins flexible qu'en Python Quand utiliser lequel ? SQL → unicité, valeur positive, champ NOT NULL Python → règles multi-champs, conditions métier

1. @api.depends — champs calculés

Un champ calculé est un champ Odoo dont la valeur est produite par une méthode Python plutôt que saisie par l'utilisateur. Le décorateur @api.depends liste les champs dont dépend le calcul.

from odoo import api, models, fields

class HelpdeskTicket(models.Model):
    _name = 'helpdesk.ticket'

    deadline    = fields.Date(string='Échéance')
    create_date = fields.Datetime(string='Créé le', readonly=True)

    # Champ calculé — store=False par défaut
    duration = fields.Float(
        string='Durée (jours)',
        compute='_compute_duration',
    )

    @api.depends('deadline', 'create_date')
    def _compute_duration(self):
        for ticket in self:
            if ticket.deadline and ticket.create_date:
                delta = ticket.deadline - ticket.create_date.date()
                ticket.duration = delta.days
            else:
                ticket.duration = 0.0

Règles importantes

  • La méthode _compute_XXX doit toujours assigner une valeur à chaque record du recordset, même si else: ticket.duration = 0.0
  • Le nom dans compute='_compute_duration' est une string (nom de la méthode), pas une référence directe
  • @api.depends doit lister tous les champs utilisés dans le calcul — Odoo invalide le cache si l'un d'eux change

2. store=True — stocker le résultat en base

Par défaut, un computed field n'est pas stocké : Odoo le recalcule à chaque lecture. Ajouter store=True crée une vraie colonne PostgreSQL et déclenche un recalcul automatique quand une dépendance change.

    # store=True → colonne is_overdue BOOLEAN en base
    # Recalculé automatiquement quand deadline ou state change
    is_overdue = fields.Boolean(
        string='En retard',
        compute='_compute_is_overdue',
        store=True,
    )

    @api.depends('deadline', 'state')
    def _compute_is_overdue(self):
        today = fields.Date.today()
        for ticket in self:
            ticket.is_overdue = (
                bool(ticket.deadline)
                and ticket.deadline < today
                and ticket.state != 'done'
            )

Comparaison store=False vs store=True

store=False store=True
Colonne PGNonOui
Filtrable en domainNonOui
Triable en vue listeNonOui
Quand recalculéÀ chaque lectureQuand une dépendance change
ConsommationCPU à chaque accèsCPU au write, stockage DB
Usage typiqueAffichage seulRecherche, filtrage, export
Piège fréquent — Un computed field store=False utilisé dans un domain de vue ou une règle de sécurité lève une erreur SQL lors du filtrage. Si le champ doit être filtrable, il doit être store=True.

3. @api.onchange — réactivité dans le formulaire

@api.onchange est déclenché quand l'utilisateur modifie un champ dans le formulaire, avant la sauvegarde. On l'utilise pour pré-remplir d'autres champs ou afficher des avertissements.

    @api.onchange('partner_id')
    def _onchange_partner_id(self):
        """Quand le client change, vider l'assignation."""
        if not self.partner_id:
            self.user_id = False

    @api.onchange('is_urgent')
    def _onchange_is_urgent(self):
        """Avertir si on marque urgent sans catégorie."""
        if self.is_urgent and not self.category_id:
            return {
                'warning': {
                    'title': 'Ticket urgent sans catégorie',
                    'message': 'Pensez à assigner une catégorie pour un ticket urgent.',
                }
            }
Différence clé avec @api.depends
@api.onchange n'est appelé que par l'interface web (formulaire). Si tu fais un ticket.write({'partner_id': 5}) en Python, l'onchange n'est PAS déclenché.
Pour une logique qui doit s'exécuter aussi en Python/batch, utilise @api.depends + un compute, ou surcharge write().

4. @api.constrains — validation Python

Le décorateur @api.constrains définit une règle de validation métier. Si la règle est violée, la méthode lève une ValidationError et la sauvegarde est annulée.

from odoo.exceptions import ValidationError

    @api.constrains('deadline', 'resolved_at')
    def _check_dates(self):
        """La résolution ne peut pas être antérieure à l'échéance."""
        for ticket in self:
            if ticket.deadline and ticket.resolved_at:
                if ticket.resolved_at.date() < ticket.deadline:
                    raise ValidationError(
                        "La date de résolution ne peut pas être antérieure à l'échéance."
                    )

Règles de fonctionnement

  • Appelé sur create() et write()
  • La liste de champs dans @api.constrains('champ1', 'champ2') définit quand la contrainte est réévaluée
  • Toujours itérer sur self — la méthode reçoit un recordset
  • Lever ValidationError (depuis odoo.exceptions), jamais UserError (réservé aux erreurs de workflow)

5. models.Constraint — contraintes SQL

Syntaxe v19 — L'ancienne syntaxe _sql_constraints = [('nom', 'UNIQUE(...)', 'message')] est dépréciée depuis Odoo 16 et supprimée en v19. La nouvelle syntaxe utilise un attribut de classe préfixé _.
class HelpdeskTicket(models.Model):
    _name = 'helpdesk.ticket'

    # ❌ DÉPRÉCIÉ — ancienne syntaxe (v15 et avant)
    # _sql_constraints = [
    #     ('unique_reference', 'UNIQUE(reference)', "Référence unique."),
    # ]

    # ✅ v19 — attribut de classe avec models.Constraint
    _unique_reference = models.Constraint(
        'UNIQUE (reference)',
        "La référence du ticket doit être unique.",
    )

    # CHECK — valeur positive
    _hours_positive = models.Constraint(
        'CHECK (hours_spent >= 0)',
        "Les heures passées ne peuvent pas être négatives.",
    )

    # UNIQUE multi-colonnes
    _unique_name_category = models.Constraint(
        'UNIQUE (name, category_id)',
        "Un ticket avec ce sujet existe déjà dans cette catégorie.",
    )

SQL vs Python — lequel choisir ?

Situation Choisir Pourquoi
Unicité d'un champ ou combinaison models.Constraint Garanti même en accès concurrent (index UNIQUE PG)
Valeur positive / dans une plage models.Constraint Plus rapide, CHECK est au niveau DB
Règle impliquant plusieurs champs relationnels @api.constrains Accès à self.env, requêtes ORM possibles
Message d'erreur traduit / conditionnel @api.constrains ValidationError supporte _() i18n

Récapitulatif — ajouts T12 dans helpdesk_ticket.py

from odoo import api, models, fields
from odoo.exceptions import ValidationError


class HelpdeskTicket(models.Model):
    _name = 'helpdesk.ticket'
    # ...attributs et champs précédents (T08→T11)...

    # ── Contrainte SQL v19 (déjà depuis T09) ──────────────────────────────────
    _unique_reference = models.Constraint(
        'UNIQUE (reference)',
        "La référence du ticket doit être unique.",
    )

    # ── Champs calculés (T12) ─────────────────────────────────────────────────

    # store=False : calculé à la volée, pas de colonne PG
    duration = fields.Float(
        string='Durée (jours)',
        compute='_compute_duration',
        help="Nombre de jours entre la date de création et l'échéance.",
    )

    # store=True : colonne PG, filtrable et triable
    is_overdue = fields.Boolean(
        string='En retard',
        compute='_compute_is_overdue',
        store=True,
    )

    @api.depends('deadline', 'create_date')
    def _compute_duration(self):
        for ticket in self:
            if ticket.deadline and ticket.create_date:
                ticket.duration = (ticket.deadline - ticket.create_date.date()).days
            else:
                ticket.duration = 0.0

    @api.depends('deadline', 'state')
    def _compute_is_overdue(self):
        today = fields.Date.today()
        for ticket in self:
            ticket.is_overdue = (
                bool(ticket.deadline)
                and ticket.deadline < today
                and ticket.state != 'done'
            )

    # ── Contrainte Python (T12) ───────────────────────────────────────────────

    @api.constrains('deadline', 'resolved_at')
    def _check_dates(self):
        for ticket in self:
            if ticket.deadline and ticket.resolved_at:
                if ticket.resolved_at.date() < ticket.deadline:
                    raise ValidationError(
                        "La date de résolution ne peut pas être antérieure à l'échéance."
                    )

Upgrade du module

./odoo-bin -c config/odoo.conf -u odooskills_helpdesk -d ta_base --stop-after-init

# Vérifier que is_overdue est bien stocké (duration ne l'est pas)
# psql -d ta_base -c "\d helpdesk_ticket" | grep overdue

Seul is_overdue apparaît dans la table — duration est calculé à la volée, sans colonne persistante.

Voir aussi dans cette série

T09 — Attributs de modèles

_order, _rec_name, models.Constraint

T10 — Champs non-relationnels

Date, Float, Boolean, Selection…

T11 — Relations

Many2one, One2many, Many2many

Prochain article — T13

On aborde l'héritage de modèles : _inherit, _inherit + _name, _inherits. Comment étendre res.partner et créer des mixins réutilisables.

Télécharger le guide technique Odoo 19 (PDF gratuit)
Relations entre modèles Odoo 19 : Many2one, One2many, Many2many
Bloc 3 · Framework ORM — Article 4/8 Relations entre modèles Odoo 19 : Many2one, One2many, Many2many Comment relier les modèles entre eux.