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.
- Avoir lu T11 — Relations entre modèles
- Connaître les types de champs Odoo 19 (T10)
- Le module
odooskills_helpdeskinstallé (version 19.0.1.3.0 minimum)
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
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_XXXdoit toujours assigner une valeur à chaque record du recordset, même sielse: 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.dependsdoit 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 PG | Non | Oui |
| Filtrable en domain | Non | Oui |
| Triable en vue liste | Non | Oui |
| Quand recalculé | À chaque lecture | Quand une dépendance change |
| Consommation | CPU à chaque accès | CPU au write, stockage DB |
| Usage typique | Affichage seul | Recherche, filtrage, export |
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.',
}
}
@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()etwrite() - 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(depuisodoo.exceptions), jamaisUserError(réservé aux erreurs de workflow)
5. models.Constraint — contraintes SQL
_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
_order, _rec_name, models.Constraint
Date, Float, Boolean, Selection…
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.