Ce que tu vas apprendre
create batch
Surcharger create avec @api.model_create_multi — la forme canonique en v19.
write protégé
Figer un enregistrement dans un état final sauf champs whitelistés.
unlink sûr
Empêcher la suppression d'un record dans un état non éligible.
Actions métier
Méthodes action_* appelables depuis les boutons XML, ensure_one(), @api.model.
1. Le cycle CRUD de l'ORM Odoo
Chaque modèle hérite de models.Model qui expose quatre opérations fondamentales :
2. Surcharger create avec @api.model_create_multi
Historiquement, create() recevait un seul dictionnaire. Depuis Odoo 13+,
la forme batch est recommandée, et en Odoo 19 c'est la seule forme acceptée
pour une nouvelle surcharge : create(vals_list) — une liste de dicts.
from odoo import api, models, fields
class HelpdeskTicket(models.Model):
_inherit = 'helpdesk.ticket'
@api.model_create_multi
def create(self, vals_list):
"""Génère automatiquement une référence si absente."""
for vals in vals_list:
if not vals.get('reference'):
vals['reference'] = self.env['ir.sequence'].sudo().next_by_code(
'helpdesk.ticket'
) or '/'
tickets = super().create(vals_list)
for ticket in tickets:
ticket.message_post(body=f"Ticket créé — {ticket.reference}")
return tickets
Points clés :
- Décorateur obligatoire — sans
@api.model_create_multi, Odoo émet un deprecation warning à l'install et refuse carrément la classe dans certains cas. - Signature
(self, vals_list)—selfest vide ici,vals_listest une liste de dicts, une par record à créer. super().create(vals_list)— passe la main à l'ORM qui retourne le recordset des tickets créés (plusieurs d'un coup si batch).sudo()surir.sequence— un utilisateur lambda n'a pas accès en écriture aux séquences. C'est l'un des rares cas légitimes desudo().
create(vals) sans le
décorateur @api.model_create_multi coupe les créations par lot (ex. import,
copy_multi, onchange), crée des incohérences et déclenche un warning au
chargement du module.
3. Déclarer la séquence ir.sequence
Pour que next_by_code('helpdesk.ticket') retourne HLP/2026/00001,
la séquence doit exister. On la crée en data XML (chargée à l'install) :
<!-- data/ir_sequence.xml -->
<odoo noupdate="1">
<record id="seq_helpdesk_ticket" model="ir.sequence">
<field name="name">Helpdesk Ticket Reference</field>
<field name="code">helpdesk.ticket</field>
<field name="prefix">HLP/%(year)s/</field>
<field name="padding">5</field>
<field name="number_increment">1</field>
<field name="number_next">1</field>
</record>
</odoo>
Et dans le manifest :
'data': [
'security/ir.model.access.csv',
'data/ir_sequence.xml', # ← nouveau
],
Le noupdate="1" est crucial : on protège la séquence d'un reset à chaque
upgrade du module (sinon le compteur repartirait à 1 en écrasant les valeurs courantes).
4. write : figer un état final
Pattern très fréquent : un ticket résolu ne doit plus être modifié — sauf la note de
résolution et les tags. On surcharge write :
from odoo.exceptions import ValidationError
class HelpdeskTicket(models.Model):
_inherit = 'helpdesk.ticket'
_EDITABLE_WHEN_DONE = {'resolution_note', 'tag_ids', 'message_follower_ids'}
def write(self, vals):
protected = set(vals) - self._EDITABLE_WHEN_DONE
if protected:
for ticket in self:
if ticket.state == 'done':
raise ValidationError(
f"Ticket {ticket.reference} résolu : seuls "
f"{', '.join(sorted(self._EDITABLE_WHEN_DONE))} "
"restent modifiables."
)
if vals.get('state') == 'done':
vals.setdefault('resolved_at', fields.Datetime.now())
return super().write(vals)
Observations :
- Signature
(self, vals)— un seul dict, pas une liste. La forme batch n'existe pas pour write :selfest déjà un recordset de N éléments,valss'applique à chacun. - Boucle
for ticket in self— nécessaire car les records du recordset peuvent être dans des états différents. - Whitelist plutôt que blacklist — on liste les champs autorisés, pas les interdits. Safer (un nouveau champ est protégé par défaut).
vals.setdefaultplutôt quevals[...] = ...— si l'appelant fournit déjàresolved_at, on respecte sa valeur.
5. unlink : protéger les états critiques
On refuse la suppression d'un ticket en cours ou résolu — seul un ticket
Nouveau est éligible. L'ORM appelle unlink sans arguments :
def unlink(self):
for ticket in self:
if ticket.state != 'new':
raise ValidationError(
f"Ticket {ticket.reference} ({ticket.state}) : "
"seuls les tickets 'Nouveau' peuvent être supprimés."
)
return super().unlink()
Deux fautes fréquentes à éviter :
- Oublier
super().unlink()— le record resterait en base, ses dépendances seraient orphelines. - Retourner autre chose que le résultat de
super()— certains callers attendent unbool, d'autres un nombre. Retourne toujours tel quel.
6. Les décorateurs @api à connaître
| Décorateur | self |
Quand l'utiliser |
|---|---|---|
@api.model |
Recordset vide | Méthodes qui ne dépendent pas d'un record précis : usines, recherches globales,
default_get, name_search… |
@api.model_create_multi |
Recordset vide | Unique forme pour surcharger create en Odoo 19. |
@api.depends('f1', 'f2') |
Recordset concerné | Champs calculés (voir T12). |
@api.constrains('f') |
Recordset à valider | Contraintes Python — levé au create/write. |
@api.onchange('f') |
Record virtuel (form) | UI seulement — recalculs côté client avant save. Pas persisté. |
| Aucun décorateur | Recordset complet | Méthode d'instance standard : actions métier, action_*, helpers. |
7. Méthodes métier : la convention action_*
Tout ce qui est déclenché par un bouton XML ou par une action serveur porte le préfixe
action_ : action_confirm, action_cancel,
action_done. Pratique pour grep, et ça signale l'intention.
def action_start(self):
"""Bascule le ticket en 'En cours'."""
self.ensure_one()
if self.state != 'new':
raise ValidationError("Seul un ticket 'Nouveau' peut être démarré.")
self.write({'state': 'in_progress'})
return True
def action_resolve(self):
"""Clôture le ticket."""
self.ensure_one()
if self.state == 'done':
return True
self.write({'state': 'done'})
return True
@api.model
def count_open_tickets(self):
"""Méthode globale — @api.model car ne dépend pas d'un record."""
return self.search_count([('state', '!=', 'done')])
Trois idiomes à intégrer :
self.ensure_one()— garantit que la méthode est appelée sur un seul record. LèveValueErrorsinon. À utiliser dans toutes les actions de boutons single-record.- Déléguer à
writeplutôt qu'accéder aux colonnes directement :self.state = 'done'fonctionne aussi, maisself.write({...})applique proprement toutes les hooks (tracking, compute, contraintes). - Retourner
Trueou un dict d'action client — pour un bouton simple,Truesuffit. Pour ouvrir une vue, retourner un dictir.actions.act_window.
Upgrade du module et test rapide
./odoo-bin -c config/odoo.conf -u odooskills_helpdesk -d ta_base --stop-after-init
# Dans un shell Odoo : créer un ticket — la référence doit être auto-générée
./odoo-bin shell -c config/odoo.conf -d ta_base
>>> t = env['helpdesk.ticket'].create({'name': 'Test T15'})
>>> t.reference # → 'HLP/2026/00001'
>>> t.message_ids.mapped('body') # → ['Ticket créé — HLP/2026/00001']
>>> t.action_start()
>>> t.state # → 'in_progress'
>>> t.action_resolve()
>>> t.resolved_at # → datetime auto-rempli par write()
>>> t.unlink() # → ValidationError : état 'done'
Les 5 pièges à éviter
- Oublier
super()— casse tracking, mail.thread, compute stored. - Surcharger
createsans@api.model_create_multi— deprecated en v19. - Modifier
valsaprèssuper()— inutile, le record est déjà créé. Tout prétraitement doit être fait avantsuper(). - Ne pas faire
ensure_one()sur une action de bouton — si l'action est déclenchée sur plusieurs records, le code casse à la première incohérence. - Utiliser
sudo()à la légère — seulement pourir.sequence,ir.config_parameter,ir.model.access. Jamais pour bypasser des droits métier.
Voir aussi dans cette série
T12 — Contraintes et champs calculés
@api.depends, @api.constrains
_inherit, _inherits, mixins
parent_id, _parent_store
Fin du Bloc 3 — Framework ORM 🎉
Tu maîtrises maintenant modèles, champs, relations, contraintes, héritage, hiérarchies et méthodes CRUD. Le Bloc 4 attaque les vues : Form, List, Kanban, Search, menus et actions.
Télécharger le guide technique Odoo 19 (PDF gratuit)